diff --git a/apps/ai-game-creator-shell/tests/ResourceDependencyOverlay.test.ts b/apps/ai-game-creator-shell/tests/ResourceDependencyOverlay.test.ts index bc73e068c..9547282a9 100644 --- a/apps/ai-game-creator-shell/tests/ResourceDependencyOverlay.test.ts +++ b/apps/ai-game-creator-shell/tests/ResourceDependencyOverlay.test.ts @@ -11,8 +11,11 @@ import { import React from 'react'; import { describe, expect, it, vi } from 'vitest'; -import type { ProjectResourceCanvasPosition } from '../../../packages/shared/src/contracts/gameCreationApp'; -import type { ProjectResourceCanvasCategory } from '../../../packages/shared/src/contracts/gameCreationApp'; +import { + isProjectResourceCanvasCategory, + type ProjectResourceCanvasCategory, + type ProjectResourceCanvasPosition, +} from '../../../packages/shared/src/contracts/gameCreationApp'; import { RESOURCE_CANVAS_CARD_WIDTH, RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP, @@ -130,7 +133,11 @@ function OverlayHarness({ overlayRef?: React.Ref; }) { const sections = Array.from( - new Set(positions.map((position) => position.section)), + new Set( + positions + .map((position) => position.section) + .filter(isProjectResourceCanvasCategory), + ), ); return React.createElement( 'div', @@ -809,7 +816,7 @@ describe('ResourceDependencyOverlay', () => { it('keeps drag previews in plane-local coordinates', async () => { const getBoundingClientRect = vi .spyOn(HTMLElement.prototype, 'getBoundingClientRect') - .mockImplementation(function () { + .mockImplementation(function (this: HTMLElement) { const isPlane = this.hasAttribute('data-resource-section-plane'); return { x: isPlane ? 160 : 20, @@ -1046,7 +1053,9 @@ describe('ResourceDependencyOverlay', () => { .map((value) => value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4, ); - return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722; + return ( + channels[0]! * 0.2126 + channels[1]! * 0.7152 + channels[2]! * 0.0722 + ); }; const lineLuminance = luminance('c45f20'); const canvasLuminance = luminance('fffdfa'); diff --git a/apps/ai-game-creator-shell/tests/ResourceEditSurface.test.tsx b/apps/ai-game-creator-shell/tests/ResourceEditSurface.test.tsx index 4ff959c69..49c1d78f9 100644 --- a/apps/ai-game-creator-shell/tests/ResourceEditSurface.test.tsx +++ b/apps/ai-game-creator-shell/tests/ResourceEditSurface.test.tsx @@ -2,13 +2,16 @@ import React from 'react'; import { describe, expect, it, vi } from 'vitest'; -import { ResourceEditSurface } from '../src/view/project-development/ResourceEditSurface'; +import { + type ResourceEditSubmitInput, + ResourceEditSurface, +} from '../src/view/project-development/ResourceEditSurface'; import { fireEvent, render, screen, waitFor } from './appSurface/harness'; describe('统一资源编辑面板', () => { it('失败后锁定原请求字段并使用相同输入重试', async () => { const submit = vi - .fn<(input: { prompt: string; assetName: string }) => Promise>() + .fn<[input: ResourceEditSubmitInput], Promise>() .mockRejectedValueOnce(new Error('网络暂不可用')) .mockResolvedValueOnce(undefined); render( diff --git a/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts index 24b61408c..c45157e5b 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts @@ -68,18 +68,20 @@ function renderAuthSurface( ) { const bridge = installTauriBridge(invoke, options); render( - React.createElement(AuthenticatedClient, null, ({ user, logout }) => - React.createElement( - 'main', - { 'aria-label': '已登录' }, - React.createElement('span', null, user.displayName), + // React 19 的 createElement 的 children 形参只接受 ReactNode,渲染函数子节点改编进 props。 + React.createElement(AuthenticatedClient, { + children: ({ user, logout }) => React.createElement( - 'button', - { type: 'button', onClick: logout }, - '退出', + 'main', + { 'aria-label': '已登录' }, + React.createElement('span', null, user.displayName), + React.createElement( + 'button', + { type: 'button', onClick: logout }, + '退出', + ), ), - ), - ), + }), ); return bridge; } diff --git a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts index e8c6f5ee7..973df9365 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts @@ -40,6 +40,19 @@ type InvokeOverrides = Record< (args: Record | undefined) => unknown >; +/** + * 安装测试用 Tauri 替身。 + * + * `vi.fn` 的返回类型是固定签名,装不进 `window.__TAURI__` 声明的泛型签名(invoke/listen 都是 + * 泛型函数),这里在装入点整体显式转换,行为与直接赋值一致。 + */ +function installTauriRuntime(runtime: { + core: { invoke: unknown }; + event?: { listen: unknown }; +}) { + window.__TAURI__ = runtime as unknown as Window['__TAURI__']; +} + function gameCreatorConfigView(reasoningEffort: string) { return { path: '/tmp/chat-composer-config.json', @@ -108,10 +121,10 @@ async function openDirectCodexSurface( return chatHarness.invoke(command, args); }, ); - window.__TAURI__ = { + installTauriRuntime({ core: { invoke }, event: { listen: chatHarness.listen }, - }; + }); renderLauncherProjectsAt('/?launcher'); pickProjectFromLauncher(DYNAMIC_GAME_PROJECT_PATH); const surface = await screen.findByLabelText('陶泥儿项目对话'); @@ -325,7 +338,7 @@ export function registerChatComposerControlTests() { const manifestAssets = [ { id: 'asset:hero', - kind: 'character', + kind: 'character' as const, mediaType: 'image/png', localPath: 'assets/hero.png', source: { kind: 'uploaded' as const }, diff --git a/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts index d96c09714..a7b4c6a5a 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts @@ -1,3 +1,5 @@ +import type { Mock } from 'vitest'; + import { act, App, @@ -12,16 +14,46 @@ import { waitFor, } from './harness'; -function renderDesignAgent( - harness: ReturnType, -) { +type ChatRuntimeHarness = ReturnType; + +/** + * 安装测试用 Tauri 替身。 + * + * harness 的 invoke/listen 是 `vi.fn` 的固定签名,装不进 `window.__TAURI__` 声明的泛型签名 + * (invoke/listen 都是泛型函数),这里在装入点整体显式转换,行为与直接赋值一致。 + */ +function installTauriRuntime(harness: ChatRuntimeHarness) { window.__TAURI__ = { core: { invoke: harness.invoke }, event: { listen: harness.listen }, - }; + } as unknown as Window['__TAURI__']; +} + +/** + * harness.invoke 的返回类型被 harness 内全部命令分支收敛成字面量联合,用例覆盖命令时要返回 + * 联合外的自定义值(例如导入文件路径),这里按固定签名 mock 取用,只放宽返回值类型。 + */ +type LooseInvokeMock = Mock< + [command: string, args?: Record], + Promise +>; + +function looseInvoke(harness: ChatRuntimeHarness): LooseInvokeMock { + return harness.invoke as unknown as LooseInvokeMock; +} + +// App 的 props 类型未导出,React 19 的 createElement 重载推断不出参数类型,这里显式转成组件类型。 +const AppComponent = App as React.ComponentType< + NonNullable> +>; + +function renderDesignAgent( + harness: ReturnType, +) { + installTauriRuntime(harness); window.history.pushState({}, '', '/'); return render( - React.createElement(App, { + React.createElement(AppComponent, { initialProjectPath: harness.projectPath, planningStartMode: true, }), @@ -127,13 +159,14 @@ export function registerDesignAgentSurfaceTests() { designAgentView: designConversationView(), designAgentContinueView: designConversationView(), }); - const originalInvoke = harness.invoke.getMockImplementation()!; + const invoke = looseInvoke(harness); + const originalInvoke = invoke.getMockImplementation()!; let failImport = true; let finishImport!: () => void; const importGate = new Promise((resolve) => { finishImport = resolve; }); - harness.invoke.mockImplementation(async (command, args) => { + invoke.mockImplementation(async (command, args) => { if (command === 'import_design_workspace_file') { await importGate; if (args?.fileName === '补充.md' && failImport) @@ -224,8 +257,9 @@ export function registerDesignAgentSurfaceTests() { const harness = createProjectChatRuntimeHarness({ designAgentView: designApprovalView(), }); - const originalInvoke = harness.invoke.getMockImplementation()!; - harness.invoke.mockImplementation(async (command, args) => + const invoke = looseInvoke(harness); + const originalInvoke = invoke.getMockImplementation()!; + invoke.mockImplementation(async (command, args) => command === 'import_design_workspace_file' ? 'references/需求.md' : originalInvoke(command, args), @@ -367,13 +401,10 @@ export function registerDesignAgentSurfaceTests() { ], }, }); - window.__TAURI__ = { - core: { invoke: harness.invoke }, - event: { listen: harness.listen }, - }; + installTauriRuntime(harness); window.history.pushState({}, '', '/'); render( - React.createElement(App, { + React.createElement(AppComponent, { initialProjectPath: harness.projectPath, planningStartMode: true, }), @@ -411,9 +442,10 @@ export function registerDesignAgentSurfaceTests() { const harness = createProjectChatRuntimeHarness({ designAgentView: designConversationView(), }); - const originalInvoke = harness.invoke.getMockImplementation()!; + const invoke = looseInvoke(harness); + const originalInvoke = invoke.getMockImplementation()!; let finish!: (view: unknown) => void; - harness.invoke.mockImplementation((command, args) => + invoke.mockImplementation((command, args) => command === 'continue_design_agent_session' ? new Promise((resolve) => { finish = resolve; @@ -467,12 +499,13 @@ export function registerDesignAgentSurfaceTests() { const harness = createProjectChatRuntimeHarness({ designAgentView: { ...designConversationView(), messages: [history] }, }); - const originalInvoke = harness.invoke.getMockImplementation()!; + const invoke = looseInvoke(harness); + const originalInvoke = invoke.getMockImplementation()!; const requests: { clientTurnId: string; finish: (view: unknown) => void; }[] = []; - harness.invoke.mockImplementation((command, args) => + invoke.mockImplementation((command, args) => command === 'continue_design_agent_session' ? new Promise((resolve) => { requests.push({ @@ -489,7 +522,7 @@ export function registerDesignAgentSurfaceTests() { await setComposerText(input, '继续'); fireEvent.submit(input.closest('form')!); await waitFor(() => expect(requests).toHaveLength(1)); - const request = requests[0]; + const request = requests[0]!; const messageId = `${request.clientTurnId}:response:0`; const reply = '整块返回也逐步显示'; const terminal = { @@ -543,7 +576,7 @@ export function registerDesignAgentSurfaceTests() { screen.getByRole('button', { name: '思考中' }).hasAttribute('disabled'), ).toBe(true); expect(screen.queryByText('迟到旧回复')).toBeNull(); - await act(async () => requests[1].finish(terminal)); + await act(async () => requests[1]!.finish(terminal)); }); } @@ -732,13 +765,10 @@ export function registerDesignAgentSurfaceTests() { const harness = createProjectChatRuntimeHarness({ designAgentView: designApprovalView(), }); - window.__TAURI__ = { - core: { invoke: harness.invoke }, - event: { listen: harness.listen }, - }; + installTauriRuntime(harness); window.history.pushState({}, '', '/'); render( - React.createElement(App, { + React.createElement(AppComponent, { initialProjectPath: harness.projectPath, planningStartMode: true, }), @@ -778,13 +808,10 @@ export function registerDesignAgentSurfaceTests() { }, }, }); - window.__TAURI__ = { - core: { invoke: harness.invoke }, - event: { listen: harness.listen }, - }; + installTauriRuntime(harness); window.history.pushState({}, '', '/'); render( - React.createElement(App, { + React.createElement(AppComponent, { initialProjectPath: harness.projectPath, planningStartMode: true, }), @@ -816,13 +843,10 @@ export function registerDesignAgentSurfaceTests() { designAgentView: designConversationView(), designAgentContinueView: designConversationView(), }); - window.__TAURI__ = { - core: { invoke: harness.invoke }, - event: { listen: harness.listen }, - }; + installTauriRuntime(harness); window.history.pushState({}, '', '/'); render( - React.createElement(App, { + React.createElement(AppComponent, { initialProjectPath: harness.projectPath, planningStartMode: true, }), @@ -886,13 +910,10 @@ export function registerDesignAgentSurfaceTests() { const harness = createProjectChatRuntimeHarness({ designAgentView: designHistoryView(), }); - window.__TAURI__ = { - core: { invoke: harness.invoke }, - event: { listen: harness.listen }, - }; + installTauriRuntime(harness); window.history.pushState({}, '', '/'); render( - React.createElement(App, { + React.createElement(AppComponent, { initialProjectPath: harness.projectPath, planningStartMode: true, }), @@ -911,9 +932,9 @@ export function registerDesignAgentSurfaceTests() { (element) => element.getAttribute('data-agent-content') === 'process', ), ).toBe(true); - fireEvent.click(summaries[0]); - expect(details[0].open).toBe(true); - expect(details[1].open).toBe(false); - await waitFor(() => expect(summaries[0].textContent).toBe('思考过程')); + fireEvent.click(summaries[0]!); + expect(details[0]!.open).toBe(true); + expect(details[1]!.open).toBe(false); + await waitFor(() => expect(summaries[0]!.textContent).toBe('思考过程')); }); } diff --git a/apps/ai-game-creator-shell/tests/appSurface/harness.ts b/apps/ai-game-creator-shell/tests/appSurface/harness.ts index 6f51990da..85f623c6c 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/harness.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/harness.ts @@ -195,18 +195,20 @@ function pickProjectFromLauncher( projectPath: string, action: '打开项目' | '新建项目' = '打开项目', ) { - const invoke = window.__TAURI__?.core?.invoke; - if (!invoke) { + const tauri = window.__TAURI__; + const invoke = tauri?.core?.invoke; + if (!tauri || !invoke) { throw new Error('Tauri invoke is not configured'); } window.__TAURI__ = { - ...window.__TAURI__, + ...tauri, core: { - ...window.__TAURI__.core, - invoke: async (command, args) => + ...tauri.core, + // 泛型 invoke 无法由非泛型包装函数精确表达:命中目录选择命令时固定返回路径,其余转发。 + invoke: (async (command: string, args?: Record) => command === 'pick_local_project_directory' ? projectPath - : invoke(command, args), + : invoke(command, args)) as unknown as typeof invoke, }, }; fireEvent.click(screen.getByRole('button', { name: action })); @@ -787,13 +789,13 @@ function createProjectChatRuntimeHarness({ manifestInvalidatedHandlers.delete( handler as unknown as ManifestInvalidatedHandler, ); - if (progressHandler === handler) { + if ((progressHandler as unknown) === handler) { progressHandler = null; } - if (designAgentUpdateHandler === handler) { + if ((designAgentUpdateHandler as unknown) === handler) { designAgentUpdateHandler = null; } - if (directThreadNotifyHandler === handler) { + if ((directThreadNotifyHandler as unknown) === handler) { directThreadNotifyHandler = null; } }; diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts index 800a6ce7d..fe402e716 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -28,6 +28,40 @@ import { within, } from './harness'; +type TauriInvoke = NonNullable< + NonNullable['core']>['invoke'] +>; + +type TauriListen = NonNullable< + NonNullable['event']>['listen'] +>; + +// Tauri 的 invoke / listen 都是泛型签名,而测试里的 vi.fn mock 只有具体返回类型与 +// 事件负载,非泛型函数无法直接满足泛型签名。下面两个 helper 只做类型收敛,运行时 +// 原样返回同一个 mock,调用方仍持有原引用(mock.calls 等断言不受影响)。 +function asTauriInvoke( + invoke: (command: string, args?: Record) => Promise, +): TauriInvoke { + // 泛型 invoke 与具体的非泛型 mock 结构上不可兼容,只能用 as unknown as 收敛。 + return invoke as unknown as TauriInvoke; +} + +function asTauriListen( + listen: ( + event: string, + handler: (event: { payload: unknown }) => void, + ) => Promise, +): TauriListen { + // 泛型 listen 与携带具体负载的 mock 结构上不可兼容,只能用 as unknown as 收敛。 + return listen as unknown as TauriListen; +} + +// App 的 props 参数带默认值,`React.ComponentProps` 会带上 undefined, +// createElement 因此推断不出 props;这里收窄成非空组件类型,运行时仍是同一个组件。 +const TestApp = App as React.ComponentType< + NonNullable> +>; + /** * 外壳级浮层提示的挂点。 * @@ -126,7 +160,7 @@ export function registerClientHomeTests() { } throw new Error(`unexpected invoke ${command}`); }); - window.__TAURI__ = { core: { invoke } }; + window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke) } }; renderLauncherAt('/?launcher'); // 挂载即加载目录,触发按钮直接落在默认模型上,不出现「选择模型」空态。 @@ -174,7 +208,7 @@ export function registerClientHomeTests() { if (command === 'read_game_creator_app_config') return { config: {} }; throw new Error(`unexpected invoke ${command}`); }); - window.__TAURI__ = { core: { invoke } }; + window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke) } }; renderLauncherAt('/?launcher'); await waitFor(() => expect(invoke).toHaveBeenCalledWith('get_game_template_library_access'), @@ -256,7 +290,7 @@ export function registerClientHomeTests() { } throw new Error(`unexpected invoke ${command}`); }); - window.__TAURI__ = { core: { invoke } }; + window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke) } }; renderLauncherAt('/?launcher'); // 首页推荐位只展示封面、标题、运行时与已下载徽标,本机灵感图库已随模板库上线删除。 @@ -308,7 +342,7 @@ export function registerClientHomeTests() { assets: [ { id: 'live-hero', - kind: 'icon-spritesheet', + kind: 'icon-spritesheet' as const, mediaType: 'image/png', localPath: 'assets/live-hero.png', source: { kind: 'generated' as const, taskId: 'art-asset-plan' }, @@ -398,7 +432,7 @@ export function registerClientHomeTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke) } }; render( React.createElement(WorkspaceLauncherShell, { currentUser: testAuthUser, @@ -541,8 +575,8 @@ export function registerClientHomeTests() { }, ); window.__TAURI__ = { - core: { invoke }, - event: { listen: runtimeHarness.listen }, + core: { invoke: asTauriInvoke(invoke) }, + event: { listen: asTauriListen(runtimeHarness.listen) }, }; render( React.createElement(WorkspaceLauncherShell, { @@ -714,8 +748,8 @@ export function registerClientHomeTests() { }, ); window.__TAURI__ = { - core: { invoke }, - event: { listen: runtimeHarness.listen }, + core: { invoke: asTauriInvoke(invoke) }, + event: { listen: asTauriListen(runtimeHarness.listen) }, }; render( React.createElement(WorkspaceLauncherShell, { @@ -820,7 +854,7 @@ export function registerClientHomeTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke) } }; renderLauncherProjectsAt('/?launcher'); expect(screen.getByLabelText('GameAgent 客户端')).not.toBeNull(); @@ -1017,7 +1051,7 @@ export function registerClientHomeTests() { return undefined; }, ); - window.__TAURI__ = { core: { invoke } }; + window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke) } }; renderLauncherAt('/?launcher'); fireEvent.click(screen.getByRole('button', { name: '充值' })); @@ -1117,7 +1151,7 @@ export function registerClientHomeTests() { } return undefined; }); - window.__TAURI__ = { core: { invoke } }; + window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke) } }; renderLauncherAt('/?launcher'); fireEvent.click(screen.getByRole('button', { name: '我的' })); @@ -1166,7 +1200,9 @@ export function registerClientHomeTests() { export function registerHomeProjectCreationTests() { it('scopes the 策划补全 option to the game entry and clears it on creation-type switch', () => { - window.__TAURI__ = { core: { invoke: vi.fn(async () => null) } }; + window.__TAURI__ = { + core: { invoke: asTauriInvoke(vi.fn(async () => null)) }, + }; renderLauncherAt('/?launcher', 'home', true); const creationTypes = screen.getByRole('group', { name: '创作类型' }); @@ -1224,8 +1260,8 @@ export function registerHomeProjectCreationTests() { }, ); window.__TAURI__ = { - core: { invoke }, - event: { listen: chatHarness.listen }, + core: { invoke: asTauriInvoke(invoke) }, + event: { listen: asTauriListen(chatHarness.listen) }, }; renderLauncherAt('/?launcher', 'home', true); @@ -1279,7 +1315,7 @@ export function registerHomeProjectCreationTests() { 'genarrative-ai-game-creator.recent-workspaces.v1', JSON.stringify(['/tmp/authorized-game']), ); - window.__TAURI__ = { core: { invoke } }; + window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke) } }; renderLauncherProjectsAt('/?launcher'); expect(await screen.findByText('authorized-game')).not.toBeNull(); @@ -1329,7 +1365,7 @@ export function registerHomeProjectCreationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke) } }; renderLauncherProjectsAt('/?launcher'); pickProjectFromLauncher('/tmp/missing-game'); @@ -1371,7 +1407,7 @@ export function registerHomeProjectCreationTests() { } throw new Error(`unexpected invoke ${command}`); }); - window.__TAURI__ = { core: { invoke } }; + window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke) } }; renderLauncherProjectsAt('/?launcher'); @@ -1424,7 +1460,7 @@ export function registerHomeProjectCreationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke) } }; renderLauncherProjectsAt('/?launcher'); fireEvent.click(screen.getByRole('button', { name: '打开项目' })); @@ -1457,7 +1493,7 @@ export function registerHomeProjectCreationTests() { } throw new Error(`unexpected invoke ${command}`); }); - window.__TAURI__ = { core: { invoke } }; + window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke) } }; renderLauncherProjectsAt('/?launcher'); fireEvent.click(screen.getByRole('button', { name: '打开项目' })); @@ -1477,7 +1513,7 @@ export function registerHomeProjectCreationTests() { } throw new Error(`unexpected invoke ${command}`); }); - window.__TAURI__ = { core: { invoke } }; + window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke) } }; renderLauncherProjectsAt('/?launcher'); const openButton = screen.getByRole('button', { name: '打开项目' }); @@ -1537,8 +1573,8 @@ export function registerHomeProjectCreationTests() { }, ); window.__TAURI__ = { - core: { invoke }, - event: { listen: chatHarness.listen }, + core: { invoke: asTauriInvoke(invoke) }, + event: { listen: asTauriListen(chatHarness.listen) }, }; renderLauncherAt('/?launcher', 'home', true); @@ -1684,8 +1720,8 @@ export function registerHomeProjectCreationTests() { }, ); window.__TAURI__ = { - core: { invoke }, - event: { listen: chatHarness.listen }, + core: { invoke: asTauriInvoke(invoke) }, + event: { listen: asTauriListen(chatHarness.listen) }, }; renderLauncherAt('/?launcher', 'home', true); @@ -1796,7 +1832,7 @@ export function registerHomeProjectCreationTests() { } throw new Error(`unexpected invoke ${command}`); }); - window.__TAURI__ = { core: { invoke } }; + window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke) } }; renderLauncherAt('/?launcher', 'home', true); const promptInput = screen.getByLabelText('创作想法'); @@ -1868,7 +1904,7 @@ export function registerHomeProjectCreationTests() { } throw new Error(`unexpected invoke ${command}`); }); - window.__TAURI__ = { core: { invoke } }; + window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke) } }; renderLauncherAt('/?launcher', 'home', true); await act(async () => { await vi.advanceTimersByTimeAsync(0); @@ -1963,8 +1999,8 @@ export function registerHomeProjectCreationTests() { }, ); window.__TAURI__ = { - core: { invoke }, - event: { listen: chatHarness.listen }, + core: { invoke: asTauriInvoke(invoke) }, + event: { listen: asTauriListen(chatHarness.listen) }, }; renderLauncherAt('/?launcher', 'home', true); @@ -2030,8 +2066,8 @@ export function registerHomeProjectCreationTests() { }, ); window.__TAURI__ = { - core: { invoke }, - event: { listen: chatHarness.listen }, + core: { invoke: asTauriInvoke(invoke) }, + event: { listen: asTauriListen(chatHarness.listen) }, }; renderLauncherAt('/?launcher', 'home', true); @@ -2127,8 +2163,8 @@ export function registerHomeProjectCreationTests() { }, ); window.__TAURI__ = { - core: { invoke }, - event: { listen: chatHarness.listen }, + core: { invoke: asTauriInvoke(invoke) }, + event: { listen: asTauriListen(chatHarness.listen) }, }; renderLauncherAt('/?launcher', 'home', true); @@ -2265,8 +2301,8 @@ export function registerHomeProjectCreationTests() { }, ); window.__TAURI__ = { - core: { invoke }, - event: { listen: chatHarness.listen }, + core: { invoke: asTauriInvoke(invoke) }, + event: { listen: asTauriListen(chatHarness.listen) }, }; renderLauncherAt('/?launcher', 'home', true); @@ -2331,8 +2367,8 @@ export function registerHomeProjectCreationTests() { }, ); window.__TAURI__ = { - core: { invoke }, - event: { listen: runtimeHarness.listen }, + core: { invoke: asTauriInvoke(invoke) }, + event: { listen: asTauriListen(runtimeHarness.listen) }, }; renderLauncherAt('/?launcher', 'home', true); @@ -2360,7 +2396,7 @@ export function registerHomeProjectCreationTests() { const committedAssets = [ { id: 'direct-art-spec', - kind: 'icon-spec', + kind: 'icon-spec' as const, mediaType: 'image/png', localPath: 'assets/art-spec.png', source: { @@ -2370,7 +2406,7 @@ export function registerHomeProjectCreationTests() { }, { id: 'direct-game-background', - kind: 'scene', + kind: 'scene' as const, mediaType: 'image/png', localPath: 'assets/direct-game-background.png', source: { @@ -2380,7 +2416,7 @@ export function registerHomeProjectCreationTests() { }, { id: 'direct-art-spritesheet', - kind: 'icon-spritesheet', + kind: 'icon-spritesheet' as const, mediaType: 'image/png', localPath: 'assets/art-spritesheet.png', source: { @@ -2506,10 +2542,10 @@ export function registerHomeProjectCreationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke) } }; render( - React.createElement(App, { + React.createElement(TestApp, { initialProjectPath: projectPath, initialProjectManifest: manifest, initialPlanningPrompt: '继续修改已有项目', @@ -2590,10 +2626,10 @@ export function registerHomeProjectCreationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke) } }; render( - React.createElement(App, { + React.createElement(TestApp, { initialProjectPath: projectPath, initialProjectManifest: manifest, initialPlanningPrompt: '生成一个游戏', @@ -2624,7 +2660,7 @@ export function registerHomeProjectCreationTests() { // 重进项目:被拒消息不得从历史里被重放,拒绝文案也不该被当成历史条目。 render( - React.createElement(App, { + React.createElement(TestApp, { initialProjectPath: projectPath, initialProjectManifest: manifest, }), @@ -2701,7 +2737,7 @@ export function registerRecentProjectsTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke) } }; window.localStorage.setItem( 'genarrative-ai-game-creator.recent-workspaces.v1', JSON.stringify(paths), @@ -2772,7 +2808,7 @@ export function registerRecentProjectsTests() { return undefined; }, ); - window.__TAURI__ = { core: { invoke } }; + window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke) } }; renderLauncherAt('/?launcher'); expect(screen.getByLabelText('最近项目')).not.toBeNull(); @@ -2831,7 +2867,11 @@ export function registerRecentProjectsTests() { '星轨夜航', ); const invoke = vi.fn( - async (command: string, args?: Record) => { + // 回调体内要读 invoke.mock.calls,显式标注返回类型以打断类型推断的自引用环。 + async ( + command: string, + args?: Record, + ): Promise => { if (command === 'get_design_agent_runtime_mode') return null; if (command === 'inspect_local_project_directory') { const renamed = @@ -2858,7 +2898,7 @@ export function registerRecentProjectsTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke) } }; window.localStorage.setItem( 'genarrative-ai-game-creator.recent-workspaces.v1', JSON.stringify([projectPath]), @@ -2906,7 +2946,7 @@ export function registerRecentProjectsTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke) } }; window.localStorage.setItem( 'genarrative-ai-game-creator.recent-workspaces.v1', JSON.stringify([projectPath]), @@ -2955,7 +2995,7 @@ export function registerRecentProjectsTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke) } }; window.localStorage.setItem( 'genarrative-ai-game-creator.recent-workspaces.v1', JSON.stringify(['/tmp/recent-one']), @@ -2994,7 +3034,7 @@ export function registerRecentProjectsTests() { } throw new Error(`unexpected invoke ${command}`); }); - window.__TAURI__ = { core: { invoke } }; + window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke) } }; renderLauncherProjectsAt('/?launcher'); pickProjectFromLauncher('/tmp/open-failed-game'); @@ -3044,7 +3084,7 @@ export function registerRecentProjectsTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke) } }; window.localStorage.setItem( 'genarrative-ai-game-creator.recent-workspaces.v1', JSON.stringify([ @@ -3191,7 +3231,7 @@ export function registerRecentProjectsTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke) } }; window.localStorage.setItem( 'genarrative-ai-game-creator.recent-workspaces.v1', JSON.stringify(['/tmp/refreshable-game']), @@ -3256,7 +3296,7 @@ export function registerRecentProjectsTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke) } }; renderLauncherAt('/?launcher'); expect(screen.getByLabelText('最近项目')).not.toBeNull(); @@ -3304,7 +3344,7 @@ export function registerRecentProjectsTests() { } throw new Error(`unexpected invoke ${command}`); }); - window.__TAURI__ = { core: { invoke } }; + window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke) } }; renderLauncherProjectsAt('/?launcher'); fireEvent.click(screen.getByRole('button', { name: '打开项目' })); @@ -3334,7 +3374,7 @@ export function registerRecentProjectsTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke) } }; renderLauncherProjectsAt('/?launcher'); fireEvent.click(screen.getByRole('button', { name: '新建项目' })); @@ -3375,7 +3415,7 @@ export function registerRecentProjectsTests() { }, ); const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false); - window.__TAURI__ = { core: { invoke } }; + window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke) } }; renderLauncherProjectsAt('/?launcher'); fireEvent.click(screen.getByRole('button', { name: '新建项目' })); @@ -3421,7 +3461,7 @@ export function registerRecentProjectsTests() { }, ); const confirm = vi.spyOn(window, 'confirm').mockReturnValue(true); - window.__TAURI__ = { core: { invoke } }; + window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke) } }; renderLauncherProjectsAt('/?launcher'); fireEvent.click(screen.getByRole('button', { name: '新建项目' })); @@ -3474,7 +3514,7 @@ export function registerRecentProjectsTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke) } }; renderLauncherProjectsAt('/?launcher'); fireEvent.click(screen.getByRole('button', { name: '新建项目' })); @@ -3501,7 +3541,7 @@ export function registerRecentProjectsTests() { } throw new Error(`unexpected invoke ${command}`); }); - window.__TAURI__ = { core: { invoke } }; + window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke) } }; renderLauncherProjectsAt('/?launcher'); fireEvent.click(screen.getByRole('button', { name: '新建项目' })); @@ -3569,8 +3609,8 @@ export function registerRecentProjectsTests() { }, ); window.__TAURI__ = { - core: { invoke }, - event: { listen: supervisorHarness.listen }, + core: { invoke: asTauriInvoke(invoke) }, + event: { listen: asTauriListen(supervisorHarness.listen) }, }; renderLauncherAt('/?launcher', 'home', true); diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts index 967302480..713ce074f 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts @@ -20,6 +20,19 @@ import { within, } from './harness'; +/** + * 安装测试用 Tauri 替身。 + * + * `vi.fn` 的返回类型是固定签名,装不进 `window.__TAURI__` 声明的泛型签名(invoke/listen 都是 + * 泛型函数),这里在装入点整体显式转换,行为与直接赋值一致。 + */ +function installTauriRuntime(runtime: { + core: { invoke: unknown }; + event?: { listen: unknown }; +}) { + window.__TAURI__ = runtime as unknown as Window['__TAURI__']; +} + export function registerProjectConversationTests() { it('splits structured rejections by variant and never by copy', () => { // 入队失败是**结构化**载荷:分流只看 `instanceof` 与 `error.type`,文案不参与任何判断, @@ -192,7 +205,7 @@ export function registerProjectConversationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game'); invoke.mockClear(); fireEvent.click(screen.getByRole('button', { name: /拆解创作方向/ })); @@ -266,7 +279,7 @@ export function registerProjectConversationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game'); invoke.mockClear(); fireEvent.click(screen.getByRole('button', { name: /拆解创作方向/ })); @@ -352,7 +365,7 @@ export function registerProjectConversationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game'); invoke.mockClear(); fireEvent.click(screen.getByRole('button', { name: /拆解创作方向/ })); @@ -428,7 +441,7 @@ export function registerProjectConversationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game'); fireEvent.click(screen.getByRole('button', { name: /拆解创作方向/ })); const input = await screen.findByLabelText('Agent 对话内容'); @@ -520,7 +533,7 @@ export function registerProjectConversationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game'); fireEvent.click(screen.getByRole('button', { name: /拆解创作方向/ })); const input = await screen.findByLabelText('Agent 对话内容'); @@ -616,7 +629,7 @@ export function registerProjectConversationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game'); fireEvent.click(screen.getByRole('button', { name: /拆解创作方向/ })); @@ -685,7 +698,7 @@ export function registerProjectConversationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game'); fireEvent.click(screen.getByRole('button', { name: /拆解创作方向/ })); const input = await screen.findByLabelText('Agent 对话内容'); @@ -787,7 +800,7 @@ export function registerProjectConversationTests() { } throw new Error(`unexpected listen ${eventName}`); }); - window.__TAURI__ = { core: { invoke }, event: { listen } }; + installTauriRuntime({ core: { invoke }, event: { listen } }); renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game'); fireEvent.click(screen.getByRole('button', { name: /拆解创作方向/ })); const input = await screen.findByLabelText('Agent 对话内容'); @@ -876,7 +889,7 @@ export function registerProjectConversationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game'); fireEvent.click(screen.getByRole('button', { name: /拆解创作方向/ })); expect(await screen.findByText('旧 Agent 历史消息')).not.toBeNull(); diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index fd8e5bebf..d096e6a29 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -2,6 +2,7 @@ import userEvent from '@testing-library/user-event'; import type { GameCreationAppAssetKind, + GameCreationAppManifest, ProjectResourceCanvasLayout, ProjectResourceCanvasPosition, } from '../../../../packages/shared/src/contracts/gameCreationApp'; @@ -51,6 +52,27 @@ import { within, } from './harness'; +/** + * 安装测试用 Tauri 替身。 + * + * `vi.fn` 的返回类型是固定签名,装不进 `window.__TAURI__` 声明的泛型签名(invoke 是泛型函数), + * 这里在装入点整体显式转换,行为与直接赋值一致。 + */ +function installTauriRuntime(runtime: { core: { invoke: unknown } }) { + // `vi.fn` 固定签名装不进 `window.__TAURI__` 的泛型签名,这里显式转换,运行时行为不变。 + window.__TAURI__ = runtime as unknown as Window['__TAURI__']; +} + +/** + * 历史夹具沿用的旧 kind 字面量(如 `design-document` / `game-code`)已不在当前 + * `GameCreationAppAssetKind` 联合里;这些用例依赖未识别 kind 落「待归类」的运行时语义, + * 所以保留原始字符串,只在这里做类型层收口,不做别名归一。 + */ +function legacyAssetKind(kind: string): GameCreationAppAssetKind { + // 旧 kind 字面量与当前联合结构上不可兼容,这里显式转换以保留原始串的运行时语义。 + return kind as unknown as GameCreationAppAssetKind; +} + function resourceGraphForInputs(args?: Record) { const resources = (args?.resources as @@ -311,7 +333,7 @@ function installResourceBookBottomToolbarInvoke( throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); return { calls }; } @@ -561,7 +583,7 @@ export function registerProjectWorkbenchFoundationTests() { source: { kind: 'generated', taskId: 'art-asset-plan' }, }, ]; - window.__TAURI__ = { + installTauriRuntime({ core: { invoke: vi.fn( async (command: string, args?: Record) => { @@ -595,7 +617,7 @@ export function registerProjectWorkbenchFoundationTests() { }, ), }, - }; + }); render( React.createElement(ProjectDevelopmentView, { @@ -686,7 +708,7 @@ export function registerProjectWorkbenchFoundationTests() { manifest.assets = [ { id: 'section-document', - kind: 'design-document', + kind: legacyAssetKind('design-document'), mediaType: 'text/markdown', localPath: 'memory/section.md', source: { kind: 'generated', taskId: 'design-foundation' }, @@ -707,7 +729,7 @@ export function registerProjectWorkbenchFoundationTests() { }, { id: 'section-code', - kind: 'game-code', + kind: legacyAssetKind('game-code'), mediaType: 'text/javascript', localPath: 'game/section.js', source: { kind: 'generated', taskId: 'code-prototype' }, @@ -767,7 +789,7 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { @@ -1340,7 +1362,7 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { @@ -1584,7 +1606,7 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { @@ -1706,7 +1728,7 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { @@ -1797,7 +1819,7 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { @@ -1939,7 +1961,7 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { @@ -2075,7 +2097,7 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { @@ -2172,7 +2194,7 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { @@ -2232,7 +2254,7 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { @@ -2294,7 +2316,7 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { @@ -2357,7 +2379,7 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { @@ -2455,7 +2477,7 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); const viewProps = { projectName: manifest.name, projectPath: '/tmp/workbench-resource-unread', @@ -2482,7 +2504,7 @@ export function registerProjectWorkbenchFoundationTests() { nextProjectManifest.assets = [ { id: 'next-project-code', - kind: 'game-code', + kind: legacyAssetKind('game-code'), mediaType: 'text/javascript', localPath: 'game/game.js', source: { kind: 'generated' }, @@ -2518,7 +2540,7 @@ export function registerProjectWorkbenchFoundationTests() { manifest.assets = [ { id: 'nav-document', - kind: 'design-document', + kind: legacyAssetKind('design-document'), mediaType: 'text/markdown', localPath: 'memory/nav-document.md', source: { kind: 'generated', taskId: 'design-foundation' }, @@ -2549,7 +2571,7 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { @@ -2635,7 +2657,7 @@ export function registerProjectWorkbenchFoundationTests() { manifest.assets = [ { id: 'imported-design-doc', - kind: 'design-document', + kind: legacyAssetKind('design-document'), mediaType: 'text/markdown', localPath: 'docs/plan.md', source: { kind: 'generated' }, @@ -2674,7 +2696,7 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); const viewProps = { projectName: manifest.name, projectPath: '/tmp/workbench-imported-image-canvas', @@ -2697,7 +2719,7 @@ export function registerProjectWorkbenchFoundationTests() { id: 'imported-image', // canonical `icon` → 「UI 交互」:本用例钉的是"同数量 manifest 更新后栏目跟着变", // 不掺 UI 设计图「(待视觉验收)」标签那套,所以选一个不会带来额外后缀的 kind。 - kind: 'icon', + kind: 'icon' as const, mediaType: 'image/png', localPath: 'assets/uploads/local-imported-image.png', source: { @@ -2728,7 +2750,7 @@ export function registerProjectWorkbenchFoundationTests() { 'workbench-empty-section-overview', '空资源项目', ); - window.__TAURI__ = { + installTauriRuntime({ core: { invoke: vi.fn( async (command: string, args?: Record) => { @@ -2762,7 +2784,7 @@ export function registerProjectWorkbenchFoundationTests() { }, ), }, - }; + }); render( React.createElement(ProjectDevelopmentView, { @@ -2843,7 +2865,7 @@ export function registerProjectWorkbenchFoundationTests() { }, { id: 'design-document', - kind: 'design-document', + kind: legacyAssetKind('design-document'), mediaType: 'text/markdown', localPath: 'memory/design.md', source: { kind: 'generated', taskId: 'design-foundation' }, @@ -2926,7 +2948,7 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); const play = vi .spyOn(HTMLMediaElement.prototype, 'play') .mockResolvedValue(undefined); @@ -3226,7 +3248,7 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); const props = { projectName: manifest.name, projectPath: '/tmp/workbench-preview-scheduler', @@ -3651,7 +3673,7 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { @@ -3800,7 +3822,7 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { @@ -3900,7 +3922,7 @@ export function registerProjectWorkbenchFoundationTests() { manifest.assets.push( { id: 'design-document', - kind: 'design-document', + kind: legacyAssetKind('design-document'), mediaType: 'text/markdown', localPath: 'game/design.md', source: { kind: 'generated' }, @@ -3921,14 +3943,14 @@ export function registerProjectWorkbenchFoundationTests() { }, { id: 'audio-bgm', - kind: 'bgm', + kind: legacyAssetKind('bgm'), mediaType: 'audio/mpeg', localPath: 'assets/bgm.mp3', source: { kind: 'generated' }, }, { id: 'blocked-document', - kind: 'design-document', + kind: legacyAssetKind('design-document'), mediaType: 'text/markdown', localPath: 'game/blocked.md', source: { kind: 'generated' }, @@ -4017,7 +4039,7 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue(undefined); render( @@ -4190,7 +4212,7 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue(undefined); const viewProps = { projectName: manifest.name, @@ -4260,7 +4282,7 @@ export function registerProjectWorkbenchFoundationTests() { manifest.assets.push( { id: 'dependency-spec', - kind: 'metadata', + kind: legacyAssetKind('metadata'), mediaType: 'application/json', localPath: 'assets/spec-source.json', source: { @@ -4271,7 +4293,7 @@ export function registerProjectWorkbenchFoundationTests() { }, { id: 'dependency-ui', - kind: 'metadata', + kind: legacyAssetKind('metadata'), mediaType: 'application/json', localPath: 'assets/ui-dependency.json', source: { @@ -4283,7 +4305,7 @@ export function registerProjectWorkbenchFoundationTests() { }, { id: 'unrelated-cycle', - kind: 'metadata', + kind: legacyAssetKind('metadata'), mediaType: 'application/json', localPath: 'assets/unrelated-cycle.json', source: { @@ -4440,7 +4462,7 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); const view = render( React.createElement(ProjectDevelopmentView, { @@ -4963,11 +4985,15 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { projectName: '延迟依赖图测试', + recentRunStatus: null, + recentRunStopReason: null, + onHomeOpen: vi.fn(), + onProjectsOpen: vi.fn(), projectPath, manifest, attachments: [], @@ -5050,11 +5076,15 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); const renderView = (agentResults: ProjectAgentResultSummary[]) => React.createElement(ProjectDevelopmentView, { projectName: '稳定依赖图测试', + recentRunStatus: null, + recentRunStopReason: null, + onHomeOpen: vi.fn(), + onProjectsOpen: vi.fn(), projectPath, manifest, attachments: [], @@ -5117,7 +5147,7 @@ export function registerProjectWorkbenchFoundationTests() { manifest.assets.push( { id: 'truncated-depth-0', - kind: 'design-spec', + kind: legacyAssetKind('design-spec'), mediaType: 'application/json', localPath: 'assets/truncated-depth-0.json', source: { @@ -5127,7 +5157,7 @@ export function registerProjectWorkbenchFoundationTests() { }, { id: 'truncated-depth-1', - kind: 'metadata', + kind: legacyAssetKind('metadata'), mediaType: 'application/json', localPath: 'assets/truncated-depth-1.json', source: { @@ -5138,7 +5168,7 @@ export function registerProjectWorkbenchFoundationTests() { }, { id: 'truncated-depth-2', - kind: 'metadata', + kind: legacyAssetKind('metadata'), mediaType: 'application/json', localPath: 'assets/truncated-depth-2.json', source: { @@ -5297,11 +5327,15 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { projectName: '截断依赖图布局保护测试', + recentRunStatus: null, + recentRunStopReason: null, + onHomeOpen: vi.fn(), + onProjectsOpen: vi.fn(), projectPath, manifest, attachments: [], @@ -5389,12 +5423,16 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); function renderWorkbench() { render( React.createElement(ProjectDevelopmentView, { projectName: '布局持久化测试', + recentRunStatus: null, + recentRunStopReason: null, + onHomeOpen: vi.fn(), + onProjectsOpen: vi.fn(), projectPath, manifest, attachments: [], @@ -5529,11 +5567,15 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { projectName: '卡片点击不误判拖动测试', + recentRunStatus: null, + recentRunStopReason: null, + onHomeOpen: vi.fn(), + onProjectsOpen: vi.fn(), projectPath, manifest, attachments: [], @@ -5608,7 +5650,7 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { @@ -5633,18 +5675,18 @@ export function registerProjectWorkbenchFoundationTests() { hasPointerCapture: { configurable: true, value: vi.fn(() => true) }, releasePointerCapture: { configurable: true, value: vi.fn() }, }); - fireEvent.pointerDown(card, { + fireEvent.pointerDown(card!, { pointerId: 41, button: 0, clientX: 40, clientY: 50, }); - fireEvent.pointerMove(card, { + fireEvent.pointerMove(card!, { pointerId: 41, clientX: 120, clientY: 50, }); - fireEvent.pointerCancel(card, { pointerId: 41 }); + fireEvent.pointerCancel(card!, { pointerId: 41 }); fireEvent.click(card!); expect(card?.querySelector('[aria-pressed="true"]')).not.toBeNull(); @@ -5712,11 +5754,15 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { projectName: '资源子类型排序测试', + recentRunStatus: null, + recentRunStopReason: null, + onHomeOpen: vi.fn(), + onProjectsOpen: vi.fn(), projectPath: '/tmp/workbench-layout-asset-subtypes', manifest, attachments: [], @@ -5776,11 +5822,15 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { projectName: '布局失败测试', + recentRunStatus: null, + recentRunStopReason: null, + onHomeOpen: vi.fn(), + onProjectsOpen: vi.fn(), projectPath: '/tmp/workbench-layout-save-failure', manifest, attachments: [], @@ -6067,7 +6117,7 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { projectName: manifest.name, @@ -6211,7 +6261,7 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); const viewProps = { projectName: manifest.name, projectPath: '/tmp/workbench-preview-scope-switch-a', @@ -6427,7 +6477,7 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); const onPlay = vi.fn(); render( @@ -6604,7 +6654,7 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { projectName: manifest.name, @@ -6613,7 +6663,7 @@ export function registerProjectWorkbenchFoundationTests() { attachments: [], recentRunStatus: null, recentRunStopReason: null, - supervisor: React.createElement('div', null, '项目总控'), + chat: React.createElement('div', null, '项目总控'), onHomeOpen: vi.fn(), onProjectsOpen: vi.fn(), }), @@ -6717,7 +6767,7 @@ export function registerProjectAgentStatusTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game'); expect(screen.queryByRole('button', { name: '调度 Ready' })).toBeNull(); @@ -6748,7 +6798,7 @@ export function registerProjectAgentStatusTests() { manifest.assets = [ { id: 'section-card-document', - kind: 'design-document', + kind: legacyAssetKind('design-document'), mediaType: 'text/markdown', localPath: 'memory/section-card.md', source: { kind: 'generated', taskId: 'design-foundation' }, @@ -6772,7 +6822,7 @@ export function registerProjectAgentStatusTests() { }, { id: 'section-card-unclassified', - kind: 'game-code', + kind: legacyAssetKind('game-code'), mediaType: 'text/javascript', localPath: 'game/section-card.js', source: { kind: 'generated', taskId: 'code-prototype' }, @@ -6804,7 +6854,7 @@ export function registerProjectAgentStatusTests() { const invoke = vi.fn( async (command: string, args?: Record) => { if (command === 'read_local_project_resource_graph') { - return resourceGraphForInputs([]); + return resourceGraphForInputs(); } if (command === 'read_local_project_resource_canvas_layout') { return { @@ -6833,7 +6883,7 @@ export function registerProjectAgentStatusTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { @@ -6894,7 +6944,7 @@ export function registerProjectAgentStatusTests() { manifest.assets = [ { id: 'all-resources-document', - kind: 'design-document', + kind: legacyAssetKind('design-document'), mediaType: 'text/markdown', localPath: 'memory/all-resources.md', source: { kind: 'generated', taskId: 'design-foundation' }, @@ -6932,7 +6982,7 @@ export function registerProjectAgentStatusTests() { const invoke = vi.fn( async (command: string, args?: Record) => { if (command === 'read_local_project_resource_graph') { - return resourceGraphForInputs([]); + return resourceGraphForInputs(); } if (command === 'read_local_project_resource_canvas_layout') { return { @@ -6961,7 +7011,7 @@ export function registerProjectAgentStatusTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { @@ -7232,7 +7282,7 @@ export function registerProjectAgentStatusTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { @@ -7313,14 +7363,14 @@ export function registerProjectAgentStatusTests() { manifest.assets = [ { id: 'overview-heading-document', - kind: 'design-document', + kind: legacyAssetKind('design-document'), mediaType: 'text/markdown', localPath: 'memory/overview-heading.md', source: { kind: 'generated', taskId: 'design-foundation' }, category: 'document', }, ]; - window.__TAURI__ = { + installTauriRuntime({ core: { invoke: vi.fn( async (command: string, args?: Record) => { @@ -7354,7 +7404,7 @@ export function registerProjectAgentStatusTests() { }, ), }, - }; + }); render( React.createElement(ProjectDevelopmentView, { @@ -7426,7 +7476,7 @@ export function registerProjectAgentStatusTests() { }, { id: 'toolbar-document', - kind: 'design-document', + kind: legacyAssetKind('design-document'), mediaType: 'text/markdown', localPath: 'memory/toolbar-notes.md', source: { kind: 'generated', taskId: 'design-foundation' }, @@ -7831,7 +7881,7 @@ export function registerProjectAgentStatusTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { @@ -8013,7 +8063,7 @@ export function registerProjectAgentStatusTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { @@ -8105,7 +8155,7 @@ export function registerProjectAgentStatusTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { @@ -8446,7 +8496,7 @@ export function registerProjectAgentStatusTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); render( React.createElement(ProjectDevelopmentView, { diff --git a/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts index 38595583d..19da5e36c 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts @@ -19,6 +19,16 @@ import { within, } from './harness'; +/** + * 安装测试用 Tauri invoke 替身。 + * + * `vi.fn` 的返回类型是固定签名,装不进 `window.__TAURI__` 声明的泛型签名(invoke 是泛型 + * 函数),这里在装入点整体显式转换,行为与直接赋值一致。 + */ +function installTauriRuntime(runtime: { core: { invoke: unknown } }) { + window.__TAURI__ = runtime as unknown as Window['__TAURI__']; +} + export function registerAgentStatusDerivationTests() { it('keeps fixed overlays below the in-page window title bar', () => { const styles = fs.readFileSync( @@ -111,6 +121,9 @@ export function registerAgentStatusDerivationTests() { pass: 1, agent: 'Evaluator', phase: 'evaluation', + taskId: null, + group: null, + role: null, status: 'passed', inputPaths: ['.agent/passes/pass-1/draft.json', '.agent/findings.md'], outputPaths: ['.agent/findings.md'], @@ -119,11 +132,14 @@ export function registerAgentStatusDerivationTests() { { toolId: 'agent.evaluate', status: 'ok', + inputPaths: [], + outputPaths: [], summary: 'Evaluator 质量评审', }, ], }, ], + artifacts: [], taskGraph: { goal: '做一个厨房弹幕游戏', readyTaskIds: ['audio-director'], @@ -220,7 +236,7 @@ export function registerRuntimeSettingsTests() { } throw new Error(`unexpected invoke ${command}`); }); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderLauncherAt('/?launcher'); fireEvent.click(screen.getByRole('button', { name: '配置' })); @@ -269,7 +285,7 @@ export function registerRuntimeSettingsTests() { } throw new Error(`unexpected invoke ${command}`); }); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderLauncherAt('/?launcher'); fireEvent.click(screen.getByRole('button', { name: '配置' })); @@ -338,7 +354,7 @@ export function registerRuntimeSettingsTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderLauncherAt('/?launcher'); fireEvent.click(screen.getByRole('button', { name: '配置' })); @@ -412,7 +428,7 @@ export function registerRuntimeSettingsTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderLauncherAt('/?launcher'); fireEvent.click(screen.getByRole('button', { name: '配置' })); @@ -484,7 +500,7 @@ export function registerRuntimeSettingsTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderLauncherAt('/?launcher'); fireEvent.click(screen.getByRole('button', { name: '配置' })); @@ -536,7 +552,7 @@ export function registerRuntimeSettingsTests() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderLauncherAt('/?launcher'); fireEvent.click(screen.getByRole('button', { name: '配置' })); @@ -617,7 +633,7 @@ export function registerRuntimeSettingsTests() { } throw new Error(`unexpected invoke ${command}`); }); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); document.documentElement.style.overflow = 'auto'; document.body.style.overflow = 'scroll'; renderLauncherAt('/?launcher'); @@ -659,7 +675,7 @@ export function registerRuntimeSettingsTests() { } throw new Error(`unexpected invoke ${command}`); }); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderLauncherAt('/?launcher'); fireEvent.click(screen.getByRole('button', { name: '配置' })); @@ -715,7 +731,7 @@ export function registerRuntimeSettingsTests() { } throw new Error(`unexpected invoke ${command}`); }); - window.__TAURI__ = { core: { invoke } }; + installTauriRuntime({ core: { invoke } }); renderLauncherAt('/?launcher'); // 首页模型选择器挂载后会在目录请求之后读取一次配置,等它完成再打开对话框。 diff --git a/apps/ai-game-creator-shell/tests/appSurface/tool-call-group.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/tool-call-group.suite.ts index 7ed499f06..c66d028ad 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/tool-call-group.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/tool-call-group.suite.ts @@ -58,8 +58,8 @@ export function registerToolCallGroupTests() { 'running', 'completed', ]); - expect(within(rows[0]).getByText('失败')).not.toBeNull(); - expect(within(rows[2]).queryByText('失败')).toBeNull(); + expect(within(rows[0]!).getByText('失败')).not.toBeNull(); + expect(within(rows[2]!).queryByText('失败')).toBeNull(); }); it('summarizes the group as its own operation count', () => { @@ -458,9 +458,9 @@ export function registerToolCallGroupTests() { view.rerender( React.createElement(ToolCallGroup, { calls: [ - { ...calls[0], status: 'completed' as const, updatedAt: lastEnd }, + { ...calls[0]!, status: 'completed' as const, updatedAt: lastEnd }, { - ...calls[1], + ...calls[1]!, status: 'completed' as const, updatedAt: lastEnd - 400, }, diff --git a/apps/ai-game-creator-shell/tests/authFailureReporting.test.tsx b/apps/ai-game-creator-shell/tests/authFailureReporting.test.tsx index b088f8435..5e82a2d42 100644 --- a/apps/ai-game-creator-shell/tests/authFailureReporting.test.tsx +++ b/apps/ai-game-creator-shell/tests/authFailureReporting.test.tsx @@ -44,9 +44,10 @@ function reportCalls() { function renderLogin(invokeAuth: AuthInvoke) { window.__TAURI__ = { core: { invoke: invokeAuth } } as never; render( - createElement(AuthenticatedClient, null, () => - createElement('main', { 'aria-label': '已登录' }), - ), + // React 19 的 createElement 的 children 形参只接受 ReactNode,渲染函数子节点改编进 props。 + createElement(AuthenticatedClient, { + children: () => createElement('main', { 'aria-label': '已登录' }), + }), ); } diff --git a/apps/ai-game-creator-shell/tests/chatComposerAttachmentCap.test.tsx b/apps/ai-game-creator-shell/tests/chatComposerAttachmentCap.test.tsx index 48d990264..13c633906 100644 --- a/apps/ai-game-creator-shell/tests/chatComposerAttachmentCap.test.tsx +++ b/apps/ai-game-creator-shell/tests/chatComposerAttachmentCap.test.tsx @@ -65,7 +65,7 @@ describe('聊天输入盒的附件上限', () => { }); expect(imported[0]).toHaveLength(5); expect(imported[1]).toHaveLength(3); - expect(imported[0].length + imported[1].length).toBe( + expect(imported[0]!.length + imported[1]!.length).toBe( MAX_CHAT_COMPOSER_ATTACHMENTS, ); }); diff --git a/apps/ai-game-creator-shell/tests/clientAnalytics.test.tsx b/apps/ai-game-creator-shell/tests/clientAnalytics.test.tsx index 043f7ec8d..3d362c71a 100644 --- a/apps/ai-game-creator-shell/tests/clientAnalytics.test.tsx +++ b/apps/ai-game-creator-shell/tests/clientAnalytics.test.tsx @@ -59,6 +59,7 @@ function mount( () => useHomeProjectCreation({ setStatus: vi.fn(), + showTransientNotice: vi.fn(), setLauncherView: vi.fn(), rememberRecentWorkspace: vi.fn(), }), @@ -88,7 +89,7 @@ it.each(['recent', 'picker', 'create'] as const)( ); }); expect(records()).toHaveLength(1); - expect(records()[0][1]).toMatchObject({ context, openSource: source }); + expect(records()[0]![1]).toMatchObject({ context, openSource: source }); }, ); @@ -105,7 +106,7 @@ it('身份抓取挂起不阻塞打开,成功时间在抓取完成前冻结', a captured.resolve(context); }); expect(records()).toHaveLength(1); - expect(Date.parse(String(records()[0][1]?.eventTime))).toBeLessThanOrEqual( + expect(Date.parse(String(records()[0]![1]?.eventTime))).toBeLessThanOrEqual( latestSuccessTime, ); }); @@ -179,7 +180,7 @@ it('同项目再次显式打开是新操作,重渲染不会产生额外打开' await result.current.openProject('C:/project', 'open'); }); expect(records()).toHaveLength(2); - expect(records()[0][1]?.operationId).not.toBe(records()[1][1]?.operationId); + expect(records()[0]![1]?.operationId).not.toBe(records()[1]![1]?.operationId); }); it('身份抓取和记录写入失败静默,不改变成功打开', async () => { diff --git a/apps/ai-game-creator-shell/tests/clientHttp.test.ts b/apps/ai-game-creator-shell/tests/clientHttp.test.ts index 30b480e52..2f9dfa43f 100644 --- a/apps/ai-game-creator-shell/tests/clientHttp.test.ts +++ b/apps/ai-game-creator-shell/tests/clientHttp.test.ts @@ -89,7 +89,7 @@ test('预设映射到渠道地址,自定义走归一化', async () => { }); test('开发态切换服务器会落盘并在内存中生效', async () => { - vi.stubEnv('DEV', true); + vi.stubEnv('DEV', 'true'); const mod = await loadClientHttp(); expect(mod.isClientServerSelectionEnabled()).toBe(true); @@ -108,7 +108,7 @@ test('开发态切换服务器会落盘并在内存中生效', async () => { }); test('开发态从 Rust 偏好恢复服务器选择', async () => { - vi.stubEnv('DEV', true); + vi.stubEnv('DEV', 'true'); const mod = await loadClientHttp(); invoke.mockResolvedValue({ clientServerSelection: { diff --git a/apps/ai-game-creator-shell/tests/clientRuntimeErrorBoundary.test.ts b/apps/ai-game-creator-shell/tests/clientRuntimeErrorBoundary.test.ts index 23d82084d..b384874b9 100644 --- a/apps/ai-game-creator-shell/tests/clientRuntimeErrorBoundary.test.ts +++ b/apps/ai-game-creator-shell/tests/clientRuntimeErrorBoundary.test.ts @@ -1,12 +1,13 @@ /** @vitest-environment jsdom */ import { fireEvent, render, screen } from '@testing-library/react'; -import { createElement } from 'react'; +import { createElement, type ReactNode } from 'react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { ClientRuntimeErrorBoundary } from '../src/app/AuthenticatedClient'; -function BrokenAuthenticatedContent() { +// 该组件永远抛错,声明 ReactNode 返回类型才能作为 children 传给错误边界。 +function BrokenAuthenticatedContent(): ReactNode { throw new Error('首页渲染失败'); } @@ -20,11 +21,11 @@ describe('AGC authenticated client error boundary', () => { const onLogout = vi.fn(); render( - createElement( - ClientRuntimeErrorBoundary, - { onLogout }, - createElement(BrokenAuthenticatedContent), - ), + createElement(ClientRuntimeErrorBoundary, { + onLogout, + // `children` 是必填 prop(createElement 第三参不再满足必填 props 约束)。 + children: createElement(BrokenAuthenticatedContent), + }), ); expect( diff --git a/apps/ai-game-creator-shell/tests/cocosProjectOpen.test.tsx b/apps/ai-game-creator-shell/tests/cocosProjectOpen.test.tsx index 26a218ac1..24a13e0ac 100644 --- a/apps/ai-game-creator-shell/tests/cocosProjectOpen.test.tsx +++ b/apps/ai-game-creator-shell/tests/cocosProjectOpen.test.tsx @@ -38,13 +38,15 @@ test('打开 Cocos 目录走导入入口,不创建 Web 脚手架或发送初 } throw new Error(`意外调用:${command}`); }); - window.__TAURI__ = { core: { invoke } }; + // TauriInvoke 是泛型函数签名,vi.fn 的具体返回类型无法直接赋给它,这里只在装配处桥接。 + window.__TAURI__ = { core: { invoke } } as unknown as typeof window.__TAURI__; const setLauncherView = vi.fn(); const { result } = renderHook(() => useHomeProjectCreation({ setStatus: vi.fn(), + // 现行 UseHomeProjectCreationOptions 必填 showTransientNotice,旧的 setAgentChatProjectPath 已移除。 + showTransientNotice: vi.fn(), setLauncherView, - setAgentChatProjectPath: vi.fn(), rememberRecentWorkspace: vi.fn(), }), ); diff --git a/apps/ai-game-creator-shell/tests/designProjectRestore.test.tsx b/apps/ai-game-creator-shell/tests/designProjectRestore.test.tsx index aaf335618..369301f23 100644 --- a/apps/ai-game-creator-shell/tests/designProjectRestore.test.tsx +++ b/apps/ai-game-creator-shell/tests/designProjectRestore.test.tsx @@ -20,7 +20,6 @@ function setup(invoke: ReturnType) { setStatus: vi.fn(), showTransientNotice: vi.fn(), setLauncherView: vi.fn(), - setAgentChatProjectPath: vi.fn(), rememberRecentWorkspace: vi.fn(), }), ); diff --git a/apps/ai-game-creator-shell/tests/designReplyAnimation.test.tsx b/apps/ai-game-creator-shell/tests/designReplyAnimation.test.tsx index e51bb4fb3..a19b54fbe 100644 --- a/apps/ai-game-creator-shell/tests/designReplyAnimation.test.tsx +++ b/apps/ai-game-creator-shell/tests/designReplyAnimation.test.tsx @@ -5,7 +5,9 @@ import { afterEach, beforeEach, expect, it, vi } from 'vitest'; import type { DesignView } from '../src/app/types'; import { useDesignReplyAnimation } from '../src/view/project-development/planning/useDesignReplyAnimation'; -beforeEach(() => vi.useFakeTimers()); +beforeEach(() => { + vi.useFakeTimers(); +}); afterEach(() => { cleanup(); vi.useRealTimers(); @@ -39,7 +41,7 @@ it('keeps each reply progress across tool snapshots and repeated completion', () result.current.receiveText(first.id, first.text); vi.advanceTimersByTime(100); }); - expect(result.current.replies[0].visible).toBe('第一'); + expect(result.current.replies[0]!.visible).toBe('第一'); act(() => result.current.receiveView( view( @@ -49,7 +51,7 @@ it('keeps each reply progress across tool snapshots and repeated completion', () ), ); expect(result.current.replies).toHaveLength(1); - expect(result.current.replies[0].visible).toBe('第一'); + expect(result.current.replies[0]!.visible).toBe('第一'); act(() => result.current.receiveText(second.id, second.text)); const terminal = view([history, first, second]); act(() => { @@ -74,9 +76,9 @@ it('resets only an unpersisted retry attempt and discards failed partial output' result.current.receiveText('t:response:0', '尝试失败'); vi.advanceTimersByTime(100); }); - expect(result.current.replies[0].visible).toBe('尝试'); + expect(result.current.replies[0]!.visible).toBe('尝试'); act(() => result.current.receiveText('t:response:0', '')); - expect(result.current.replies[0].visible).toBe(''); + expect(result.current.replies[0]!.visible).toBe(''); const saved = { id: 't:response:0', role: 'assistant', text: '成功' }; act(() => { result.current.receiveText(saved.id, saved.text); diff --git a/apps/ai-game-creator-shell/tests/dev-port.test.ts b/apps/ai-game-creator-shell/tests/dev-port.test.ts index e7a8f3036..b72ac4eb8 100644 --- a/apps/ai-game-creator-shell/tests/dev-port.test.ts +++ b/apps/ai-game-creator-shell/tests/dev-port.test.ts @@ -4,14 +4,77 @@ import { createAgcAdminWebEndpoint, createAgcDevEndpoint, readConfiguredAgcAdminWebPort, - resolveAgcAdminWebEndpoint, - resolveAgcDevEndpoint, + resolveAgcAdminWebEndpoint as resolveAgcAdminWebEndpointImpl, + resolveAgcDevEndpoint as resolveAgcDevEndpointImpl, withAgcDevEndpointEnv, } from '../scripts/dev-port.mjs'; +// scripts/dev-port.mjs 没有 JSDoc,TS 按 JS 默认值把可选参数推成了过窄类型 +// (findPort 的 portRange 推成 null、reservedPorts 推成 never[]),与测试注入的 mock 结构不兼容。 +// 下面按运行时真实契约重新声明局部签名,只做类型层校正,调用方式与行为完全不变。 +type EndpointPortRange = { start: number; end: number; label: string }; + +type AgcDevEndpointLike = { + host: string; + port: number; + url: string; + markerUrl: string; + portRange: EndpointPortRange | null; +}; + +type AgcAdminWebEndpointLike = { + host: string; + port: number; + origin: string; + basePath: string; + url: string; + portRange: EndpointPortRange | null; +}; + +type FindPortOptions = { + host?: string; + preferredPort: number; + reservedPorts?: Set; + maxAttempts?: number | null; + portRange?: EndpointPortRange | null; + strict?: boolean; +}; + +type ReservePortRangeResult = + | { range?: EndpointPortRange | null } + | null + | undefined; + +type ResolveAgcDevEndpointOptions = { + env?: NodeJS.ProcessEnv; + platform?: NodeJS.Platform; + strictConfigured?: boolean; + reservePortRange?: (options?: { + env?: NodeJS.ProcessEnv; + }) => Promise; + findPort?: (options: FindPortOptions) => number | Promise; +}; + +type ResolveAgcAdminWebEndpointOptions = ResolveAgcDevEndpointOptions & { + reservedPorts?: number[]; +}; + +// 导入实现本身是同一份,仅按运行时契约修正推导过窄的签名(js 无 JSDoc 导致结构上不兼容)。 +const resolveAgcDevEndpoint = resolveAgcDevEndpointImpl as unknown as ( + options?: ResolveAgcDevEndpointOptions, +) => Promise; + +// 同上:reservedPorts 在 JS 推断里被收窄为 never[],与实际可传入的保留端口数组不兼容。 +const resolveAgcAdminWebEndpoint = + resolveAgcAdminWebEndpointImpl as unknown as ( + options?: ResolveAgcAdminWebEndpointOptions, + ) => Promise; + describe('AI 游戏创作 dev 端口', () => { test('Linux 使用用户端口段的 start + 5 槽位', async () => { - const findPort = vi.fn(async ({ preferredPort }) => preferredPort); + const findPort = vi.fn( + async ({ preferredPort }: FindPortOptions) => preferredPort, + ); const endpoint = await resolveAgcDevEndpoint({ env: { USER: 'alice', LOGNAME: 'alice' }, platform: 'linux', @@ -44,7 +107,7 @@ describe('AI 游戏创作 dev 端口', () => { username: 'alice', range: { start: 10000, end: 10099, label: '10000-10099' }, }), - findPort: async ({ preferredPort, portRange }) => { + findPort: async ({ preferredPort, portRange }: FindPortOptions) => { expect(preferredPort).toBe(10005); expect(portRange?.label).toBe('10000-10099'); return 10006; @@ -55,7 +118,9 @@ describe('AI 游戏创作 dev 端口', () => { }); test('父启动器选定端口后子启动器严格使用同一端口', async () => { - const findPort = vi.fn(async ({ preferredPort }) => preferredPort); + const findPort = vi.fn( + async ({ preferredPort }: FindPortOptions) => preferredPort, + ); await resolveAgcDevEndpoint({ env: { USER: 'alice', @@ -134,7 +199,9 @@ describe('AI 游戏创作 dev 端口', () => { describe('AGC 开发态后台 Web 端口', () => { test('Linux 使用用户端口段的 start + 3 槽位', async () => { - const findPort = vi.fn(async ({ preferredPort }) => preferredPort); + const findPort = vi.fn( + async ({ preferredPort }: FindPortOptions) => preferredPort, + ); const endpoint = await resolveAgcAdminWebEndpoint({ env: { USER: 'alice', LOGNAME: 'alice' }, platform: 'linux', @@ -161,7 +228,9 @@ describe('AGC 开发态后台 Web 端口', () => { }); test('AGC Vite 端口作为保留端口传入,避免被后台 Web 抢先占用', async () => { - const findPort = vi.fn(async ({ preferredPort }) => preferredPort); + const findPort = vi.fn( + async ({ preferredPort }: FindPortOptions) => preferredPort, + ); await resolveAgcAdminWebEndpoint({ env: {}, platform: 'win32', @@ -170,11 +239,13 @@ describe('AGC 开发态后台 Web 端口', () => { }); const [call] = findPort.mock.calls; - expect([...call[0].reservedPorts]).toEqual([10005]); + expect([...call![0].reservedPorts!]).toEqual([10005]); }); test('非 Linux 保留 3102 兼容优先端口并允许统一漂移', async () => { - const findPort = vi.fn(async ({ preferredPort }) => preferredPort + 1); + const findPort = vi.fn( + async ({ preferredPort }: FindPortOptions) => preferredPort + 1, + ); const endpoint = await resolveAgcAdminWebEndpoint({ env: {}, platform: 'win32', @@ -189,7 +260,9 @@ describe('AGC 开发态后台 Web 端口', () => { }); test('显式 ADMIN_WEB_PORT 与脚本统一命名并支持严格占用', async () => { - const findPort = vi.fn(async ({ preferredPort }) => preferredPort); + const findPort = vi.fn( + async ({ preferredPort }: FindPortOptions) => preferredPort, + ); await resolveAgcAdminWebEndpoint({ env: { ADMIN_WEB_PORT: '3109' }, platform: 'win32', diff --git a/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx b/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx index 6cb5e3856..52c52c362 100644 --- a/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx +++ b/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx @@ -26,11 +26,15 @@ afterEach(() => { eventFake = null; }); -const ACTIVE_TURN = { +const ACTIVE_TURN: GameCreatorDirectActiveTurn = { projectPath: 'C:/projects/demo', - agentId: 'planning-agent-v2', - runId: 'run-1', -} as unknown as GameCreatorDirectActiveTurn; + projectName: '示例项目', + turnId: 'turn-1', + startedAt: 1, + status: 'running', + updatedAt: 2, + sequence: 1, +}; describe('useDirectActiveTurns', () => { it('keeps the snapshot identity when an event-triggered read returns the same content', async () => { @@ -56,7 +60,10 @@ describe('useDirectActiveTurns', () => { }); it('subscribes before the first snapshot and does not read while quiet', async () => { - const invoke = vi.fn(async () => [ACTIVE_TURN]); + const invoke = vi.fn< + [command: string], + Promise + >(async () => [ACTIVE_TURN]); eventFake = createTauriEventFake({ invoke }); eventFake.install(); const setIntervalSpy = vi.spyOn(window, 'setInterval'); @@ -235,7 +242,7 @@ describe('useDirectActiveTurns', () => { completeOld = resolve; }), ) - .mockResolvedValue([{ ...ACTIVE_TURN, runId: 'new-run' }]); + .mockResolvedValue([{ ...ACTIVE_TURN, turnId: 'new-run' }]); const { result, rerender } = renderHook( ({ enabled }) => useDirectActiveTurns({ invoke: invoke as never, enabled }), @@ -244,7 +251,7 @@ describe('useDirectActiveTurns', () => { rerender({ enabled: false }); rerender({ enabled: true }); await waitFor(() => - expect(result.current.activeTurns[0]?.runId).toBe('new-run'), + expect(result.current.activeTurns[0]?.turnId).toBe('new-run'), ); const current = result.current.activeTurns; await act(async () => { @@ -289,7 +296,7 @@ describe('ActiveProjectRunsPanel', () => { true, false, ]); - fireEvent.click(items[0]); + fireEvent.click(items[0]!); expect(onOpenProject).toHaveBeenCalledWith('C:/projects/first'); }); diff --git a/apps/ai-game-creator-shell/tests/directHistoryPaging.test.ts b/apps/ai-game-creator-shell/tests/directHistoryPaging.test.ts index 3e620c89f..cff60d505 100644 --- a/apps/ai-game-creator-shell/tests/directHistoryPaging.test.ts +++ b/apps/ai-game-creator-shell/tests/directHistoryPaging.test.ts @@ -95,7 +95,7 @@ describe('DirectProject 历史分页连拉', () => { expect(result.error).toBeNull(); const merged = selectDirectChatEntries( mergeDirectHistoryItems( - { turnRunning: false, history: existing, live: [] }, + { ...emptyDirectThreadChatState(), history: existing }, result.items, ), ); diff --git a/apps/ai-game-creator-shell/tests/directProjectProcessStatus.test.tsx b/apps/ai-game-creator-shell/tests/directProjectProcessStatus.test.tsx index 502a23058..70be0266c 100644 --- a/apps/ai-game-creator-shell/tests/directProjectProcessStatus.test.tsx +++ b/apps/ai-game-creator-shell/tests/directProjectProcessStatus.test.tsx @@ -24,6 +24,7 @@ test('运行中状态条的读秒按 100ms 刷新:不足一分钟的耗时以 vi.setSystemTime(STARTED_AT); const view = render( { try { render( & Partial, @@ -26,7 +24,15 @@ function withUserItemId( lifecycle: ThreadEvent, userItemId: string, ): ThreadEvent { - return { ...lifecycle, userItemId }; + // `userItemId` 只属于 `turn.started` / `turn.completed` 两种生命周期事件; + // 其余事件没有这个字段,原样返回。 + if ( + lifecycle.type === 'turn.started' || + lifecycle.type === 'turn.completed' + ) { + return { ...lifecycle, userItemId }; + } + return lifecycle; } /** app-server `item/started`:工具真正开始执行,itemId 就是归一后的唯一身份。 */ @@ -44,12 +50,17 @@ function toolStarted( } /** 原始 response item 的输出条目:Rust 已经把它归一成同一个 itemId。 */ -function toolOutput(): ThreadItem { +function toolOutput( + overrides: Partial< + Extract + > = {}, +): ThreadItem { return { itemType: 'function_call_output', itemId: 'call_00_Gpd0s0Ytm9YgIbwbEXva1473', output: 'assets\ngame', at: 2000, + ...overrides, }; } @@ -295,7 +306,7 @@ describe('DirectProject 聊天 reducer', () => { id: `direct-codex:${clientTurnId}:user`, content: [{ type: 'input_text', text }], }, - } as Partial); + }); } it('按事件顺序折成队列,取消与放行都只按身份移除', () => { @@ -738,12 +749,12 @@ describe('DirectProject 聊天 reducer', () => { }); it('内部工具的阶段状态:开始事件就是运行中,正式完成事件才结束', () => { - const fileChange = { + const fileChange: Extract = { itemType: 'fileChange', itemId: 'file-1', changes: [{ path: 'game/src/hero.ts', kind: 'update' }], at: 0, - } as const; + }; const started = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ event({ type: 'item.started', at: 1_000_100, item: fileChange }), ]); @@ -811,7 +822,7 @@ describe('DirectProject 聊天 reducer', () => { event({ type: 'turn.started', at: 1_000_000 }), // 宿主收口的毫秒时间:原生回合时间是秒级精度,"上一轮结束之后又来一条开始事件" // 就是新回合,不能因为时间不比终点晚就把它当重放吞掉。 - event({ type: 'turn.completed', status: 'cancelled', at: 1_000_900 }), + event({ type: 'turn.completed', status: 'aborted', at: 1_000_900 }), ]); const next = reduceDirectThreadEvents(done, [ event({ type: 'turn.started', at: 1_000_000 }), diff --git a/apps/ai-game-creator-shell/tests/directThreadChatSubscription.test.tsx b/apps/ai-game-creator-shell/tests/directThreadChatSubscription.test.tsx index 91d0061a9..f7a8ba9ac 100644 --- a/apps/ai-game-creator-shell/tests/directThreadChatSubscription.test.tsx +++ b/apps/ai-game-creator-shell/tests/directThreadChatSubscription.test.tsx @@ -18,7 +18,8 @@ afterEach(() => { eventFake?.restore(); eventFake = null; delete window.__TAURI__; - delete window.__TAURI_INTERNALS__; + delete (window as Window & { __TAURI_INTERNALS__?: unknown }) + .__TAURI_INTERNALS__; }); function installEventFake( diff --git a/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts b/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts index 7dbe863b7..e0e1dd3a7 100644 --- a/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts +++ b/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts @@ -369,9 +369,9 @@ describe('DirectProject 聊天分区', () => { // 「本轮结束于 … 」隐藏(不再出现 0.0 秒)。 expect(turns[1]?.users).toEqual([]); expect(turns[1]?.startedAt).toBe(0); - expect(turns[1]?.finals.map((block) => block.text)).toEqual([ - '待发消息已达上限(最多 5 条),请等前面几条发完再发送', - ]); + expect( + turns[1]?.finals.map((block) => ('text' in block ? block.text : '')), + ).toEqual(['待发消息已达上限(最多 5 条),请等前面几条发完再发送']); }); it('历史无用户条目时也保留一个回合承载正文', () => { diff --git a/apps/ai-game-creator-shell/tests/errorReporting.test.ts b/apps/ai-game-creator-shell/tests/errorReporting.test.ts index dc4e8e9c7..832ca4ac3 100644 --- a/apps/ai-game-creator-shell/tests/errorReporting.test.ts +++ b/apps/ai-game-creator-shell/tests/errorReporting.test.ts @@ -94,12 +94,12 @@ describe('客户端错误报告池', () => { }); it('按 fingerprint 合并重复错误并累计次数', async () => { - const first = await captureClientError(new Error('重复错误'), { + const first = (await captureClientError(new Error('重复错误'), { source: 'test', - }); - const second = await captureClientError(new Error('重复错误'), { + }))!; + const second = (await captureClientError(new Error('重复错误'), { source: 'test', - }); + }))!; expect(second.eventId).toBe(first.eventId); expect(await getPendingClientErrorEvents()).toHaveLength(1); @@ -129,12 +129,12 @@ describe('客户端错误报告池', () => { }); it('提交成功后只清除本次提交的事件', async () => { - const first = await captureClientError(new Error('第一个错误'), { + const first = (await captureClientError(new Error('第一个错误'), { source: 'test', - }); - const second = await captureClientError(new Error('第二个错误'), { + }))!; + const second = (await captureClientError(new Error('第二个错误'), { source: 'test', - }); + }))!; await submitErrorReportBatch({ events: [first], logs: [] }); await markClientErrorEventsSubmitted([first]); @@ -143,9 +143,9 @@ describe('客户端错误报告池', () => { }); it('提交只把脱敏事件交给 Rust,不在渲染层携带凭据或 submissionId', async () => { - const event = await captureClientError(new Error('可重试错误'), { + const event = (await captureClientError(new Error('可重试错误'), { source: 'test', - }); + }))!; vi.mocked(invoke).mockImplementationOnce(async (command: string) => { if (command !== 'submit_error_report') { throw new Error(`unexpected invoke ${command}`); @@ -174,9 +174,9 @@ describe('客户端错误报告池', () => { it('本地 ack 失败时自动重试且不向调用方抛错', async () => { vi.useFakeTimers(); - const event = await captureClientError(new Error('ack 重试错误'), { + const event = (await captureClientError(new Error('ack 重试错误'), { source: 'test', - }); + }))!; vi.mocked(invoke) .mockRejectedValueOnce(new Error('暂时不可用')) .mockResolvedValueOnce(undefined); @@ -197,9 +197,9 @@ describe('客户端错误报告池', () => { }); it('登录态缺失时把 Rust 的稳定分类原样透出,不清除本地事件', async () => { - const event = await captureClientError(new Error('未登录错误'), { + const event = (await captureClientError(new Error('未登录错误'), { source: 'test', - }); + }))!; vi.mocked(invoke).mockRejectedValueOnce( new Error('authentication-required: 请先登录后再提交错误报告'), ); @@ -211,7 +211,10 @@ describe('客户端错误报告池', () => { }); it('解包 ClientAuthErrorWrapper,把载体序列化后的 message 原样上报,上下文只认显式入参', async () => { - const original = { type: 'authNetworkFailure', reason: 'unreachable' }; + const original: ClientAuthError = { + type: 'authNetworkFailure', + reason: 'unreachable', + }; await captureClientError(new ClientAuthErrorWrapper(original), { source: 'unhandledrejection', diff --git a/apps/ai-game-creator-shell/tests/gameDistributionPublishLive.test.ts b/apps/ai-game-creator-shell/tests/gameDistributionPublishLive.test.ts index a55e9fee9..5ae63e1af 100644 --- a/apps/ai-game-creator-shell/tests/gameDistributionPublishLive.test.ts +++ b/apps/ai-game-creator-shell/tests/gameDistributionPublishLive.test.ts @@ -20,7 +20,7 @@ import JSZip from 'jszip'; import { expect, test, vi } from 'vitest'; import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp'; -import type { StagedGamePackage } from '../src/app/types'; +import type { StagedGamePackage, TauriInvoke } from '../src/app/types'; import { uploadPlatformMediaAsset } from '../src/services/assetDirectUpload'; import { setClientServerSelection } from '../src/services/clientHttp'; import { publishLocalProjectGame } from '../src/services/gameDistributionPublish'; @@ -461,6 +461,7 @@ liveTest( } as unknown as GameCreationAppManifest; // 记录本次发布实际发送过的分片偏移,用来证明「中断后不重传已收字节」。 const sentOffsets: number[] = []; + // 该 mock 只按命令返回固定结构,无法满足 TauriInvoke 的泛型逐命令建模,故整体断言为 TauriInvoke。 const invoke = vi.fn( async (command: string, args?: Record) => { if (command !== 'publish_local_project_game') { @@ -544,7 +545,7 @@ liveTest( fileCount: staged.packageFileCount, }; }, - ); + ) as unknown as TauriInvoke; // 服务端要求发布必须带封面。真实 Tauri 里 `upload_platform_media_asset` 由 Rust // 完成「凭证 → 对象存储直传 → confirm」;jsdom 没有原生宿主,这里用同一个 // invoke 合同按服务端协议代跑,证明渲染层只提交字节和结构化元数据。 @@ -553,14 +554,17 @@ liveTest( assetKind: 'game_distribution_cover', pathSegments: ['game-distribution', 'cover', stamp], entityId: 'game-distribution-cover', - invoke: async (command, args) => { + invoke: async ( + command: string, + args?: Record, + ): Promise => { if (command !== 'upload_platform_media_asset') { throw new Error(`未预期的命令:${command}`); } - return await simulateNativePlatformAssetUpload( + return (await simulateNativePlatformAssetUpload( args as { input: Record }, token, - ); + )) as T; }, }); expect(uploadedCover.assetObjectId).toMatch(/\S/u); diff --git a/apps/ai-game-creator-shell/tests/gameDistributionPublishPanel.test.tsx b/apps/ai-game-creator-shell/tests/gameDistributionPublishPanel.test.tsx index ddc3458b8..98de312bf 100644 --- a/apps/ai-game-creator-shell/tests/gameDistributionPublishPanel.test.tsx +++ b/apps/ai-game-creator-shell/tests/gameDistributionPublishPanel.test.tsx @@ -115,10 +115,11 @@ function renderPanel( beforeEach(() => { vi.mocked(readGameCoverGenerationPrice).mockImplementation( - () => new Promise(() => undefined), + // 永不落定的占位 Promise:用 never 兼容各返回类型,运行时行为不变。 + () => new Promise(() => undefined), ); vi.mocked(suggestGameDistributionPublishMetadata).mockImplementation( - () => new Promise(() => undefined), + () => new Promise(() => undefined), ); }); @@ -130,7 +131,9 @@ afterEach(() => { vi.mocked(suggestGameDistributionPublishMetadata).mockReset(); vi.mocked(uploadPlatformMediaAsset).mockReset(); delete (window as unknown as { __TAURI__?: unknown }).__TAURI__; - window.localStorage.clear(); + // Node 26 下 vitest 的 jsdom 不再注入 localStorage(Node 全局 localStorage 为 undefined), + // 本用例也不读写它;这里只做存在性保护,避免清理阶段抛错。 + (window.localStorage as Storage | undefined)?.clear(); }); describe('GameDistributionPublishPanel', () => { @@ -201,8 +204,9 @@ describe('GameDistributionPublishPanel', () => { await waitFor(() => { expect(suggestGameDistributionPublishMetadata).toHaveBeenCalled(); }); + // waitFor 已确保至少调用过一次,这里用非空断言取出首次调用的入参。 const [request] = vi.mocked(suggestGameDistributionPublishMetadata).mock - .calls[0]; + .calls[0]!; const context = String(request.context ?? ''); expect(context.length).toBeGreaterThanOrEqual(5_000); expect(context.length).toBeLessThanOrEqual(6_000); diff --git a/apps/ai-game-creator-shell/tests/gamePublishFeedback.test.tsx b/apps/ai-game-creator-shell/tests/gamePublishFeedback.test.tsx index 786c790a7..8077e32a8 100644 --- a/apps/ai-game-creator-shell/tests/gamePublishFeedback.test.tsx +++ b/apps/ai-game-creator-shell/tests/gamePublishFeedback.test.tsx @@ -10,6 +10,7 @@ import { readFileSync } from 'node:fs'; import { beforeEach, describe, it, vi } from 'vitest'; import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp'; +import type { ProjectPermissionPolicyView } from '../src/app/types'; import { App, createGameCreationAppManifest, @@ -77,7 +78,7 @@ function installTauri( options: { exportPackage?: () => unknown; manifest?: GameCreationAppManifest; - policy?: ReturnType; + policy?: ProjectPermissionPolicyView; readPolicy?: () => unknown; } = {}, ) { diff --git a/apps/ai-game-creator-shell/tests/homeWebPreflight.test.tsx b/apps/ai-game-creator-shell/tests/homeWebPreflight.test.tsx index 34cea47b3..bc2f95718 100644 --- a/apps/ai-game-creator-shell/tests/homeWebPreflight.test.tsx +++ b/apps/ai-game-creator-shell/tests/homeWebPreflight.test.tsx @@ -37,7 +37,6 @@ function mount(preflight: () => Promise) { setStatus: vi.fn(), showTransientNotice: vi.fn(), setLauncherView: vi.fn(), - setAgentChatProjectPath: vi.fn(), rememberRecentWorkspace: vi.fn(), }), ); diff --git a/apps/ai-game-creator-shell/tests/imageAssetSelector.test.tsx b/apps/ai-game-creator-shell/tests/imageAssetSelector.test.tsx index a012f2d60..3cb629c5a 100644 --- a/apps/ai-game-creator-shell/tests/imageAssetSelector.test.tsx +++ b/apps/ai-game-creator-shell/tests/imageAssetSelector.test.tsx @@ -9,9 +9,10 @@ import { import userEvent from '@testing-library/user-event'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { SpriteAsset } from '../src/features/ui-editor/types/SpriteAsset'; import { ImageAssetSelector } from '../src/view/ui-editor/components/Inspector/Components/ImageAssetSelector'; -const sprites = { +const sprites: Record = { hero: { asset_id: 'hero', metadata: { name: '主角', asset_type: 'sprite' }, diff --git a/apps/ai-game-creator-shell/tests/nodeTransformGeometry.test.ts b/apps/ai-game-creator-shell/tests/nodeTransformGeometry.test.ts index e0ad8356b..3108903d1 100644 --- a/apps/ai-game-creator-shell/tests/nodeTransformGeometry.test.ts +++ b/apps/ai-game-creator-shell/tests/nodeTransformGeometry.test.ts @@ -26,6 +26,7 @@ function node(id: string, transform: Transform, children: Node[] = []): Node { component: null, children_display_mode: 'Stack', children, + offset: { min: [0, 0], max: [0, 0] }, }; } @@ -86,13 +87,15 @@ describe('nodeTransformGeometry', () => { }; const oldChildRect = resolvePageRect(child.layout.transform, oldParentRect); - const [{ transform }] = resolveChildrenTransformsForParentRect( + const [resolvedChild] = resolveChildrenTransformsForParentRect( [child], oldParentRect, newParentRect, ); - expect(resolvePageRect(transform, newParentRect)).toEqual(oldChildRect); + expect(resolvePageRect(resolvedChild!.transform, newParentRect)).toEqual( + oldChildRect, + ); }); it('keeps the page rectangle stable when inspector anchors change', () => { diff --git a/apps/ai-game-creator-shell/tests/platformSession.test.ts b/apps/ai-game-creator-shell/tests/platformSession.test.ts index 10734765c..29273e89e 100644 --- a/apps/ai-game-creator-shell/tests/platformSession.test.ts +++ b/apps/ai-game-creator-shell/tests/platformSession.test.ts @@ -8,6 +8,8 @@ */ import { beforeEach, expect, test, vi } from 'vitest'; +import type { AuthUser } from '../../../packages/shared/src/contracts/auth'; + const refreshClientAuthSession = vi.hoisted(() => vi.fn()); vi.mock('../src/services/clientAuth', () => ({ @@ -24,7 +26,7 @@ import { subscribePlatformSessionGeneration, } from '../src/services/platformSession'; -const userA = { +const userA: AuthUser = { id: 'user-a', publicUserCode: 'tn-a', displayName: '账号 A', diff --git a/apps/ai-game-creator-shell/tests/pluginHost.test.ts b/apps/ai-game-creator-shell/tests/pluginHost.test.ts index 416ffeca6..66f3a9afa 100644 --- a/apps/ai-game-creator-shell/tests/pluginHost.test.ts +++ b/apps/ai-game-creator-shell/tests/pluginHost.test.ts @@ -5,7 +5,9 @@ import { startAvailableAgcEditorPlugins, } from '../src/services/pluginHost'; -afterEach(() => vi.unstubAllGlobals()); +afterEach(() => { + vi.unstubAllGlobals(); +}); const editorPlugins = [ { id: 'agc-cocos-editor', name: 'Cocos Creator' }, diff --git a/apps/ai-game-creator-shell/tests/previewRightPanDrag.test.tsx b/apps/ai-game-creator-shell/tests/previewRightPanDrag.test.tsx index aab60e92a..2773dec5f 100644 --- a/apps/ai-game-creator-shell/tests/previewRightPanDrag.test.tsx +++ b/apps/ai-game-creator-shell/tests/previewRightPanDrag.test.tsx @@ -60,12 +60,6 @@ function node( } const activeImage: UIDesignImage = { - metadata: { - name: '测试界面', - description: '', - role: null, - slave_to: null, - }, path: 'assets/page.png', pixel_size: [1200, 800], pixels_per_unit: 1, @@ -102,6 +96,7 @@ function createCanvas( sprites: {}, fontFaces: {}, tree: { src_ui_design: 'page-1', root }, + selectedTreeId: null, selectedNode: null, selectedNodeId: null, keepChildrenUnchanged: false, @@ -240,7 +235,9 @@ describe('PreviewWorkspace tree drag', () => { pointerId: 11, }); expect(committed.canvas.setTreeOffset).toHaveBeenCalledTimes(1); - expect(committed.canvas.setTreeOffset.mock.calls[0]?.[0]).toBe('page-1'); + expect(vi.mocked(committed.canvas.setTreeOffset).mock.calls[0]?.[0]).toBe( + 'page-1', + ); cleanup(); const cancelled = renderTreeDrag(); diff --git a/apps/ai-game-creator-shell/tests/previewWorkspaceZoom.test.tsx b/apps/ai-game-creator-shell/tests/previewWorkspaceZoom.test.tsx index 6570fb463..a302621da 100644 --- a/apps/ai-game-creator-shell/tests/previewWorkspaceZoom.test.tsx +++ b/apps/ai-game-creator-shell/tests/previewWorkspaceZoom.test.tsx @@ -70,6 +70,7 @@ function createCanvas( sprites: {}, fontFaces: {}, tree: { src_ui_design: 'page-1', root }, + selectedTreeId: null, selectedNode: null, selectedNodeId: null, keepChildrenUnchanged: false, @@ -85,6 +86,7 @@ function createCanvas( insertNode: vi.fn(), insertNodeAfter: vi.fn(), deleteNode: vi.fn(), + setTreeOffset: vi.fn(), openClearDialog: vi.fn(), ...overrides, }; @@ -136,7 +138,14 @@ describe('PreviewWorkspace quick zoom', () => { }); it('applies the adaptive screen-space spacing to the preview background', () => { - const rendered = render(); + const rendered = render( + , + ); const preview = screen.getByRole('region', { name: 'UI 预览画布' }); const scale = logicalViewportScale(rendered.container); const [backgroundWidth] = preview.style.backgroundSize.split('px'); @@ -147,7 +156,14 @@ describe('PreviewWorkspace quick zoom', () => { }); it('uses the toolbar zoom step while the preview is hovered', () => { - const rendered = render(); + const rendered = render( + , + ); const preview = screen.getByRole('region', { name: 'UI 预览画布' }); const before = logicalViewportScale(rendered.container); @@ -164,7 +180,14 @@ describe('PreviewWorkspace quick zoom', () => { }); it('requires hover or focus and ignores interactive targets', () => { - const rendered = render(); + const rendered = render( + , + ); const preview = screen.getByRole('region', { name: 'UI 预览画布' }); const zoomButton = screen.getByRole('button', { name: '放大画布' }); const initial = logicalViewportScale(rendered.container); @@ -202,7 +225,14 @@ describe('PreviewWorkspace quick zoom', () => { }); it('keeps the actual-size shortcut within the preview scope', () => { - const rendered = render(); + const rendered = render( + , + ); const preview = screen.getByRole('region', { name: 'UI 预览画布' }); fireEvent.focus(preview); @@ -215,7 +245,14 @@ describe('PreviewWorkspace quick zoom', () => { }); it('refits the preview when the zoom percentage button is clicked', () => { - const rendered = render(); + const rendered = render( + , + ); const fitButton = screen.getByRole('button', { name: '适配画布' }); const zoomInButton = screen.getByRole('button', { name: '放大画布' }); const initial = logicalViewportScale(rendered.container); @@ -237,6 +274,9 @@ describe('PreviewWorkspace quick zoom', () => { activeImageId: null, tree: null, })} + showFrame={false} + showOriginImage={false} + showComponent={true} />, ); @@ -268,12 +308,24 @@ describe('PreviewWorkspace quick zoom', () => { }, }; - const single = render(); + const single = render( + , + ); const singleScale = logicalViewportScale(single.container); single.unmount(); const joint = render( - , + , ); const jointScale = logicalViewportScale(joint.container); // 联合边界(两棵树横跨 1600 单位)明显比单棵树宽:首次进入必须适配全部树。 @@ -283,6 +335,9 @@ describe('PreviewWorkspace quick zoom', () => { joint.rerender( , ); expect(logicalViewportScale(joint.container)).toBe(jointScale); @@ -303,6 +358,9 @@ describe('PreviewWorkspace quick zoom', () => { canvas={createCanvas({ uiTrees: [{ src_ui_design: 'page-1', root: movedRoot }], })} + showFrame={false} + showOriginImage={false} + showComponent={true} />, ); diff --git a/apps/ai-game-creator-shell/tests/processSessionRealE2eFixture.test.ts b/apps/ai-game-creator-shell/tests/processSessionRealE2eFixture.test.ts index 0a80c86b2..327a742cc 100644 --- a/apps/ai-game-creator-shell/tests/processSessionRealE2eFixture.test.ts +++ b/apps/ai-game-creator-shell/tests/processSessionRealE2eFixture.test.ts @@ -67,7 +67,7 @@ describe('process-session real E2E fixture', () => { return new Promise((resolve, reject) => { const waiter = { predicate, - resolve: (line) => { + resolve: (line: string) => { clearTimeout(timer); waiters.delete(waiter); resolve(line); diff --git a/apps/ai-game-creator-shell/tests/projectCreationDirectory.test.ts b/apps/ai-game-creator-shell/tests/projectCreationDirectory.test.ts index 3b5fbfa05..a6a5d30e1 100644 --- a/apps/ai-game-creator-shell/tests/projectCreationDirectory.test.ts +++ b/apps/ai-game-creator-shell/tests/projectCreationDirectory.test.ts @@ -10,6 +10,31 @@ import { setProjectCreationDirectory, } from '../src/features/app-shell/workspacePreferences'; +type TauriInvoke = NonNullable< + NonNullable['core']>['invoke'] +>; + +type TauriListen = NonNullable< + NonNullable['event']>['listen'] +>; + +function asTauriInvoke( + invoke: (command: string, args?: Record) => Promise, +): TauriInvoke { + // 泛型 invoke 与具体的非泛型 mock 结构上不可兼容,只能用 as unknown as 收敛。 + return invoke as unknown as TauriInvoke; +} + +function asTauriListen( + listen: ( + event: string, + handler: (event: { payload: unknown }) => void, + ) => Promise, +): TauriListen { + // 泛型 listen 与携带具体负载的 mock 结构上不可兼容,只能用 as unknown as 收敛。 + return listen as unknown as TauriListen; +} + describe('项目创建目录偏好', () => { beforeEach(() => { resetWorkspacePreferencesForTests(); @@ -82,14 +107,16 @@ describe('项目创建目录偏好', () => { throw new Error('unexpected invoke ' + command); }); window.__TAURI__ = { - core: { invoke }, + core: { invoke: asTauriInvoke(invoke) }, event: { - listen: vi.fn(async (_event, handler) => { - eventHandler = handler; - return () => { - eventHandler = null; - }; - }), + listen: asTauriListen( + vi.fn(async (_event, handler) => { + eventHandler = handler; + return () => { + eventHandler = null; + }; + }), + ), }, }; diff --git a/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx b/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx index 574229d91..39ee08608 100644 --- a/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx +++ b/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx @@ -2,7 +2,12 @@ import React, { useState } from 'react'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp'; +import type { + GameCreationAppAssetKind, + GameCreationAppAssetManifestEntry, + GameCreationAppManifest, +} from '../../../packages/shared/src/contracts/gameCreationApp'; +import type { TauriInvoke } from '../src/app/types'; const canvasFixture = vi.hoisted(() => ({ manifest: null as GameCreationAppManifest | null, @@ -32,6 +37,26 @@ import { const projectPath = '/tmp/live-canvas-integration'; +/** + * 历史夹具沿用旧 kind `art-image` / `game-rules`,它们已不在当前 + * `GameCreationAppAssetKind` 联合里;用例依赖「未识别 kind」的运行时语义, + * 因此保留原字符串,只在类型层收口。 + */ +function legacyAssetKind(kind: string): GameCreationAppAssetKind { + // 旧 kind 字面量与当前联合结构上不可兼容,只能显式转换以保留原串的运行时语义。 + return kind as unknown as GameCreationAppAssetKind; +} + +/** + * 泛型 `invoke` 与具体的非泛型 mock 结构上不可兼容,只能用显式转换收敛类型; + * 运行时原样返回同一个 mock,调用方仍持有原引用(`mock.calls` 等断言不受影响)。 + */ +function asTauriInvoke( + invoke: (command: string, args?: Record) => Promise, +): TauriInvoke { + return invoke as unknown as TauriInvoke; +} + /** * 切栏目走「资源总览」的栏目缩略卡片(左侧大纲导航已删除)。 * 与两个 appSurface suite 里的同名助手同口径:切完必须落到目标栏目的子画布。 @@ -183,7 +208,7 @@ function LiveWorkbench() { initial.assets = [ { id: 'source-art', - kind: 'art-image', + kind: legacyAssetKind('art-image'), mediaType: 'image/png', localPath: 'assets/source-art.png', source: { kind: 'canvas', taskId: null, resourceId: 'source-resource' }, @@ -279,7 +304,7 @@ function DerivedWorkbench({ initial.assets = [ { id: 'source-rules', - kind: 'game-rules', + kind: legacyAssetKind('game-rules'), mediaType: 'text/markdown', localPath: 'docs/rules.md', source: { kind: 'generated', resourceId: 'rules-resource' }, @@ -288,11 +313,11 @@ function DerivedWorkbench({ ? [ { id: 'source-art', - kind: 'art-image', + kind: legacyAssetKind('art-image'), mediaType: 'image/png', localPath: `assets/${artName}`, source: { kind: 'generated', resourceId: 'art-resource' }, - }, + } satisfies GameCreationAppAssetManifestEntry, ] : []), ...(includeCharacter @@ -304,7 +329,7 @@ function DerivedWorkbench({ mediaType: 'image/png', localPath: `assets/${characterName}`, source: { kind: 'generated', resourceId: 'hero-resource' }, - }, + } satisfies GameCreationAppAssetManifestEntry, ] : []), ]; @@ -415,27 +440,33 @@ function MixedCategoryWorkbench() { '实时画布项目', ); initial.assets = [ - ...Array.from({ length: 6 }, (_, index) => ({ - id: `asset-doc-${index + 1}`, - kind: 'document', - category: 'document' as const, - mediaType: 'text/markdown', - localPath: `docs/note-0${index + 1}.md`, - source: { - kind: 'generated' as const, - resourceId: `doc-resource-${index + 1}`, - }, - })), - ...Array.from({ length: 6 }, (_, index) => ({ - id: `asset-image-${index + 1}`, - kind: 'art-image', - mediaType: 'image/png', - localPath: `assets/img-0${index + 1}.png`, - source: { - kind: 'generated' as const, - resourceId: `image-resource-${index + 1}`, - }, - })), + ...Array.from( + { length: 6 }, + (_, index): GameCreationAppAssetManifestEntry => ({ + id: `asset-doc-${index + 1}`, + kind: 'document', + category: 'document' as const, + mediaType: 'text/markdown', + localPath: `docs/note-0${index + 1}.md`, + source: { + kind: 'generated' as const, + resourceId: `doc-resource-${index + 1}`, + }, + }), + ), + ...Array.from( + { length: 6 }, + (_, index): GameCreationAppAssetManifestEntry => ({ + id: `asset-image-${index + 1}`, + kind: legacyAssetKind('art-image'), + mediaType: 'image/png', + localPath: `assets/img-0${index + 1}.png`, + source: { + kind: 'generated' as const, + resourceId: `image-resource-${index + 1}`, + }, + }), + ), ]; const [manifest, setManifest] = useState(initial); canvasFixture.manifest = manifest; @@ -470,7 +501,7 @@ function SwitchableDerivedWorkbench({ manifest.assets = [ { id: 'source-rules', - kind: 'game-rules', + kind: legacyAssetKind('game-rules'), mediaType: 'text/markdown', localPath: 'docs/rules.md', source: { kind: 'generated', resourceId: 'rules-resource' }, @@ -656,7 +687,7 @@ describe('project resource live canvas integration', () => { ); const asset = { id: `edit-${operationId}`, - kind: 'game-rules', + kind: legacyAssetKind('game-rules'), mediaType: 'text/markdown', localPath: `assets/edits/${operationId}-recovered-rules.md`, source: { @@ -732,7 +763,7 @@ describe('project resource live canvas integration', () => { const assetId = `edit-${operationId}`; const asset = { id: assetId, - kind: 'game-rules', + kind: legacyAssetKind('game-rules'), mediaType: 'text/markdown', localPath: `assets/edits/${operationId}-rules.md`, source: { @@ -807,7 +838,7 @@ describe('project resource live canvas integration', () => { }, ); window.__TAURI__ = { - core: { invoke }, + core: { invoke: asTauriInvoke(invoke) }, event: { listen: async () => () => undefined }, }; return { @@ -1348,7 +1379,7 @@ describe('project resource live canvas integration', () => { ); } const base = canvasFixture.manifest!; - const asset = { + const asset: GameCreationAppAssetManifestEntry = { id: `edit-${operation.operationId}`, kind: 'video', mediaType: 'video/mp4', @@ -1619,7 +1650,7 @@ describe('project resource live canvas integration', () => { ); const staleAsset = { id: 'stale-recovered-asset', - kind: 'game-rules', + kind: legacyAssetKind('game-rules'), mediaType: 'text/markdown', localPath: 'assets/stale-recovered-rules.md', source: { @@ -1773,11 +1804,11 @@ describe('project resource live canvas integration', () => { ); const actions = screen.getAllByRole('button', { name: '继续原编辑' }); - fireEvent.click(actions[0]); - fireEvent.click(actions[0]); - expect(actions[0].hasAttribute('disabled')).toBe(true); - expect(actions[1].hasAttribute('disabled')).toBe(false); - fireEvent.click(actions[1]); + fireEvent.click(actions[0]!); + fireEvent.click(actions[0]!); + expect(actions[0]!.hasAttribute('disabled')).toBe(true); + expect(actions[1]!.hasAttribute('disabled')).toBe(false); + fireEvent.click(actions[1]!); await waitFor(() => expect(resumeCalls).toHaveLength(2)); expect(resumeCalls.map((call) => call.operationId)).toEqual([ @@ -1825,8 +1856,8 @@ describe('project resource live canvas integration', () => { }), ); const actions = screen.getAllByRole('button', { name: '继续原编辑' }); - fireEvent.click(actions[0]); - fireEvent.click(actions[1]); + fireEvent.click(actions[0]!); + fireEvent.click(actions[1]!); await act(async () => { firstResume.reject(new Error('并行恢复一失败')); @@ -1894,7 +1925,7 @@ describe('project resource live canvas integration', () => { const base = canvasFixture.manifest!; const asset = { id: 'reopened-dialog-output', - kind: 'game-rules', + kind: legacyAssetKind('game-rules'), mediaType: 'text/markdown', localPath: 'assets/reopened-dialog-output.md', source: { diff --git a/apps/ai-game-creator-shell/tests/projectResourceLiveUpdateModel.test.ts b/apps/ai-game-creator-shell/tests/projectResourceLiveUpdateModel.test.ts index 08ff3209a..d9e4ee0f5 100644 --- a/apps/ai-game-creator-shell/tests/projectResourceLiveUpdateModel.test.ts +++ b/apps/ai-game-creator-shell/tests/projectResourceLiveUpdateModel.test.ts @@ -411,7 +411,10 @@ describe('素材上传后的清单快照必须配对', () => { revision: 3, manifest: manifest('project-live', []), }; - const invoke = async (command: string): Promise => { + const invoke = async ( + command: string, + _args?: Record, + ): Promise => { switch (command) { case 'upload_local_asset': if (advanceOnUpload) { diff --git a/apps/ai-game-creator-shell/tests/projectResourceProjectionModel.test.ts b/apps/ai-game-creator-shell/tests/projectResourceProjectionModel.test.ts index 829ed15c0..4a38324a9 100644 --- a/apps/ai-game-creator-shell/tests/projectResourceProjectionModel.test.ts +++ b/apps/ai-game-creator-shell/tests/projectResourceProjectionModel.test.ts @@ -45,7 +45,9 @@ describe('项目资源投影', () => { }, { id: 'registered-bgm', - kind: 'bgm', + // `bgm` 已不在 canonical kind 词表里,运行时会收口成 `unknown`(落「待归类」); + // 这里直接写真实 union 成员,保持投影结果不变。 + kind: 'unknown', mediaType: 'audio/wav', localPath: 'audio/registered-bgm.wav', source: { kind: 'generated' }, @@ -59,7 +61,8 @@ describe('项目资源投影', () => { }, { id: 'unknown-binary', - kind: 'binary', + // 真实词表用 `unknown` 表示无法解析的二进制产物,替换已退役的 `binary`。 + kind: 'unknown', mediaType: 'application/octet-stream', localPath: 'build/game.bundle', source: { kind: 'generated' }, diff --git a/apps/ai-game-creator-shell/tests/recentProjectsHook.test.tsx b/apps/ai-game-creator-shell/tests/recentProjectsHook.test.tsx index 1bbb6d1d7..c7177bab2 100644 --- a/apps/ai-game-creator-shell/tests/recentProjectsHook.test.tsx +++ b/apps/ai-game-creator-shell/tests/recentProjectsHook.test.tsx @@ -4,6 +4,31 @@ import { afterEach, expect, test, vi } from 'vitest'; import { useRecentProjects } from '../src/features/app-shell/useRecentProjects'; +type TauriInvoke = NonNullable< + NonNullable['core']>['invoke'] +>; + +type TauriListen = NonNullable< + NonNullable['event']>['listen'] +>; + +function asTauriInvoke( + invoke: (command: string, args?: Record) => Promise, +): TauriInvoke { + // 泛型 invoke 与具体的非泛型 mock 结构上不可兼容,只能用 as unknown as 收敛。 + return invoke as unknown as TauriInvoke; +} + +function asTauriListen( + listen: ( + event: string, + handler: (event: { payload: unknown }) => void, + ) => Promise, +): TauriListen { + // 泛型 listen 与携带具体负载的 mock 结构上不可兼容,只能用 as unknown as 收敛。 + return listen as unknown as TauriListen; +} + const READY_PROJECT = { projectPath: '/tmp/ready-project', exists: true, @@ -47,7 +72,7 @@ test('刷新坏项目时保留其它项目已确认的正常状态', async () => return READY_PROJECT; }, ); - window.__TAURI__ = { core: { invoke } }; + window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke) } }; window.localStorage.setItem( 'genarrative-ai-game-creator.recent-workspaces.v1', JSON.stringify(['/tmp/ready-project']), @@ -106,7 +131,7 @@ test('单次目录检查失败会就地重试,不会把整行钉成「检查 return READY_PROJECT; }, ); - window.__TAURI__ = { core: { invoke } }; + window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke) } }; window.localStorage.setItem( 'genarrative-ai-game-creator.recent-workspaces.v1', JSON.stringify(['/tmp/ready-project']), @@ -148,7 +173,7 @@ test('失败结果不跨轮保留:刷新时该项回到「检查中」并重 return { ...READY_PROJECT, projectPath }; }, ); - window.__TAURI__ = { core: { invoke } }; + window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke) } }; window.localStorage.setItem( 'genarrative-ai-game-creator.recent-workspaces.v1', JSON.stringify(['/tmp/broken-project']), @@ -193,7 +218,7 @@ test('提权类失败不重试:不放大 UAC 弹窗', async () => { ); }, ); - window.__TAURI__ = { core: { invoke } }; + window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke) } }; window.localStorage.setItem( 'genarrative-ai-game-creator.recent-workspaces.v1', JSON.stringify(['/tmp/elevation-project']), @@ -239,14 +264,16 @@ test('Rust 偏好事件与 command 返回同时到达时只检查新增项目一 }, ); window.__TAURI__ = { - core: { invoke }, + core: { invoke: asTauriInvoke(invoke) }, event: { - listen: vi.fn(async (_event, handler) => { - emitPreferenceEvent = handler; - return () => { - emitPreferenceEvent = null; - }; - }), + listen: asTauriListen( + vi.fn(async (_event, handler) => { + emitPreferenceEvent = handler; + return () => { + emitPreferenceEvent = null; + }; + }), + ), }, }; @@ -282,7 +309,7 @@ test('Rust 偏好事件与 command 返回同时到达时只检查新增项目一 ); }, ); - window.__TAURI__ = { core: { invoke } }; + window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke) } }; window.localStorage.setItem( 'genarrative-ai-game-creator.recent-workspaces.v1', JSON.stringify(['/tmp/denied-elevation-project']), diff --git a/apps/ai-game-creator-shell/tests/referenceSourceProviders.test.ts b/apps/ai-game-creator-shell/tests/referenceSourceProviders.test.ts index 05a19b7b4..68d0b8fc8 100644 --- a/apps/ai-game-creator-shell/tests/referenceSourceProviders.test.ts +++ b/apps/ai-game-creator-shell/tests/referenceSourceProviders.test.ts @@ -292,7 +292,7 @@ describe('附件 provider', () => { describe('运行画面区域 provider', () => { it('静默且只认运行画面区域 part,可选字段缺失时收成 undefined', () => { expect(runtimeRegionReferenceProvider.trigger).toBe(null); - expect(runtimeRegionReferenceProvider.match).toBeUndefined(); + expect(runtimeRegionReferenceProvider.fuzzyLookup).toBeUndefined(); expect( runtimeRegionReferenceProvider.toReference(runtimeRegionPart), ).toEqual({ diff --git a/apps/ai-game-creator-shell/tests/resourceBatchTagsIntegration.test.tsx b/apps/ai-game-creator-shell/tests/resourceBatchTagsIntegration.test.tsx index c1d0863b7..29f75a8a3 100644 --- a/apps/ai-game-creator-shell/tests/resourceBatchTagsIntegration.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceBatchTagsIntegration.test.tsx @@ -158,7 +158,7 @@ function BatchTagsHost({ recentRunStopReason={null} onHomeOpen={() => undefined} onProjectsOpen={() => undefined} - supervisor={
} + chat={
} onManifestChange={(path, next) => { manifestChanges.push({ path, projectId: next.projectId }); // 壳按当前项目身份收敛:只有当前项目的回写才落到工作台状态里。 diff --git a/apps/ai-game-creator-shell/tests/resourceBookLayout.test.ts b/apps/ai-game-creator-shell/tests/resourceBookLayout.test.ts index e2099510f..32d4dab3c 100644 --- a/apps/ai-game-creator-shell/tests/resourceBookLayout.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceBookLayout.test.ts @@ -85,7 +85,7 @@ describe('resourceBookOverviewCardLayout', () => { }); describe('selectResourceBookOverviewCards', () => { - const cards = ['a', 'b', 'c', 'd'].map(resource); + const cards = ['a', 'b', 'c', 'd'].map((id) => resource(id)); const positions = new Map([ ['a', { x: 0, y: 0 }], ['b', { x: 1_000, y: 0 }], @@ -147,7 +147,9 @@ describe('groupResourceBookStacks', () => { }); describe('buildResourceBookScenePlan', () => { - const resources = ['art-0', 'art-1', 'art-2', 'art-3'].map(resource); + const resources = ['art-0', 'art-1', 'art-2', 'art-3'].map((id) => + resource(id), + ); const positions = new Map( resources.map((item, index) => [item.id, { x: index * 200, y: 0 }]), ); @@ -156,12 +158,20 @@ describe('buildResourceBookScenePlan', () => { ); const base = { visibleCategoryOrder: ['unclassified'] as const, - resourcesByCategory: new Map([['unclassified', resources]]), - overviewRects: new Map([['unclassified', rect]]), + // 显式标注 key 为 ResourceBookTarget:否则 new Map 会推成 Map, + // 与 buildResourceBookScenePlan 的 ReadonlyMap 不兼容。 + resourcesByCategory: new Map([ + ['unclassified', resources], + ]), + overviewRects: new Map([ + ['unclassified', rect], + ]), positions, cardSizes, visibleResourceIds: new Set(resources.map((item) => item.id)), - categoryViewCenters: new Map([['unclassified', { x: 0, y: 64 }]]), + categoryViewCenters: new Map([ + ['unclassified', { x: 0, y: 64 }], + ]), cardsReady: true, }; @@ -197,11 +207,11 @@ describe('buildResourceBookScenePlan', () => { const plan = buildResourceBookScenePlan({ ...base, visibleCategoryOrder: ['unclassified', 'scene'], - resourcesByCategory: new Map([ + resourcesByCategory: new Map([ ['unclassified', resources], ['scene', [resource('code-0')]], ]), - overviewRects: new Map([ + overviewRects: new Map([ ['unclassified', rect], ['scene', { ...rect, left: 500 }], ]), @@ -335,7 +345,10 @@ describe('buildResourceBookScenePlan', () => { positions: allPositions, cardSizes: allCardSizes, visibleResourceIds: new Set(allResources.map((item) => item.id)), - categoryViewCenters: new Map([ + categoryViewCenters: new Map< + ResourceBookTarget, + { x: number; y: number } + >([ ['unclassified', { x: 0, y: 64 }], ['document', { x: 900, y: 64 }], ]), @@ -998,7 +1011,7 @@ describe('resourceBookAllLayoutKey', () => { categoryOrder: ['document', 'unclassified'] as const, resourcesByCategory: new Map([ ['document', [documentResource(1)]], - ['unclassified', [imageAssetResource(1, { width: 180, height: 180 })]], + ['unclassified', [imageAssetResource(1)]], ]), cardSizes: new Map([ ['agent-result:design:run-1', { width: 180, height: 128 }], diff --git a/apps/ai-game-creator-shell/tests/resourceBookModel.test.ts b/apps/ai-game-creator-shell/tests/resourceBookModel.test.ts index 356c85f08..e65608448 100644 --- a/apps/ai-game-creator-shell/tests/resourceBookModel.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceBookModel.test.ts @@ -147,7 +147,7 @@ describe('resource book card presentation', () => { expect(resourceBookCategoryCardPresentation(entering, 'scene')).toBe( 'child', ); - expect(resourceBookCategoryCardPresentation(entering, 'code')).toBe( + expect(resourceBookCategoryCardPresentation(entering, 'audio')).toBe( 'overview', ); }); @@ -163,7 +163,7 @@ describe('resource book card presentation', () => { ); expect(resourceBookCategoryCardPresentation(steady, 'scene')).toBe('child'); - expect(resourceBookCategoryCardPresentation(steady, 'code')).toBeNull(); + expect(resourceBookCategoryCardPresentation(steady, 'audio')).toBeNull(); }); it('returns every category to the stack and exposes the exiting window', () => { @@ -179,7 +179,7 @@ describe('resource book card presentation', () => { expect(resourceBookCategoryCardPresentation(returning, 'scene')).toBe( 'overview', ); - expect(resourceBookCategoryCardPresentation(returning, 'code')).toBe( + expect(resourceBookCategoryCardPresentation(returning, 'audio')).toBe( 'overview', ); expect(resourceBookShowsExitingCards(returning)).toBe(true); diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationQueue.test.ts b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationQueue.test.ts index da51d6641..89b49797d 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationQueue.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationQueue.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test, vi } from 'vitest'; +import type { GameCreationAppAssetKind } from '../../../packages/shared/src/contracts/gameCreationApp'; import { createResourceCanvasAssetGenerationQueue, mergeResourceCanvasAssetGenerationTasksWithRecords, @@ -21,6 +22,15 @@ import { } from '../src/features/resource-canvas/resourceCanvasAssetGenerationTaskModel'; import type { ResourceCanvasAssetToolAction } from '../src/features/resource-canvas/resourceCanvasBottomToolbarModel'; +/** + * 未识别 kind 字面量(如 `nope`)不在当前 `GameCreationAppAssetKind` 联合里;本用例依赖 + * 「未识别 kind 不编造文案」的运行时语义,因此保留原始字符串,只在类型层收口。 + */ +function unknownAssetKind(kind: string): GameCreationAppAssetKind { + // 未识别 kind 与当前联合结构上不可兼容,这里显式转换以保留原始串的运行时语义。 + return kind as unknown as GameCreationAppAssetKind; +} + const uiPrototypeAction: ResourceCanvasAssetToolAction = { id: 'generate-ui-prototype', route: 'asset', @@ -205,7 +215,9 @@ describe('生成任务模型', () => { '生成 UI 设计图', ); expect(resourceCanvasAssetGenerationKindLabel('image')).toBe('生成图片'); - expect(resourceCanvasAssetGenerationKindLabel('nope')).toBeNull(); + expect( + resourceCanvasAssetGenerationKindLabel(unknownAssetKind('nope')), + ).toBeNull(); }); test('已耗时文案按分秒呈现', () => { @@ -311,7 +323,7 @@ describe('本地排队驱动器', () => { const harness = createHarness(); let notify: (() => void) | null = null; let releaseCount = 0; - const wait = vi.fn(harness.deps.wait); + const wait = vi.fn(harness.deps.wait!); harness.deps.wait = wait; harness.deps.subscribe = async (_projectPath, _taskId, onChanged) => { notify = onChanged; @@ -328,7 +340,8 @@ describe('本地排队驱动器', () => { expect(wait).not.toHaveBeenCalled(); harness.advance('task-event', 'completed'); - notify?.(); + // 订阅回调在闭包里赋值,控制流分析仍把 notify 收窄为 null,这里显式放宽回声明类型。 + (notify as (() => void) | null)?.(); await settled; expect(wait).not.toHaveBeenCalled(); diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationReferences.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationReferences.test.tsx index 22dfdb814..e0960a072 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationReferences.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationReferences.test.tsx @@ -9,7 +9,10 @@ import { import userEvent from '@testing-library/user-event'; import { afterEach, describe, expect, test, vi } from 'vitest'; -import type { GameCreationAppAssetManifestEntry } from '../../../packages/shared/src/contracts/gameCreationApp'; +import type { + GameCreationAppAssetKind, + GameCreationAppAssetManifestEntry, +} from '../../../packages/shared/src/contracts/gameCreationApp'; import { type ChatReference, directCodexContentToPromptText, @@ -89,7 +92,7 @@ function asset( id: string, mediaType: string, localPath: string, - kind = 'image', + kind: GameCreationAppAssetKind = 'image', ): GameCreationAppAssetManifestEntry { return { id, kind, mediaType, localPath, source: { kind: 'canvas' } }; } @@ -253,8 +256,10 @@ describe('参考图模型', () => { describe('生成面板的参考接线', () => { test('图片入口呈现参考计数与引用输入区,提交带回引用', async () => { const user = userEvent.setup(); - const onSubmit = - vi.fn<(input: ResourceCanvasAssetGenerationSubmitInput) => void>(); + const onSubmit = vi.fn< + [input: ResourceCanvasAssetGenerationSubmitInput], + void + >(); const imageAsset = asset('asset-a', 'image/png', 'assets/素材-a.png'); const references = [resourceReferenceFromAsset(imageAsset, 'asset-picker')]; render( @@ -305,8 +310,10 @@ describe('生成面板的参考接线', () => { */ test('图标规范入口呈现参考计数与引用输入区,提交带回引用', async () => { const user = userEvent.setup(); - const onSubmit = - vi.fn<(input: ResourceCanvasAssetGenerationSubmitInput) => void>(); + const onSubmit = vi.fn< + [input: ResourceCanvasAssetGenerationSubmitInput], + void + >(); const imageAsset = asset('asset-a', 'image/png', 'assets/素材-a.png'); const references = [resourceReferenceFromAsset(imageAsset, 'asset-picker')]; render( @@ -422,8 +429,10 @@ describe('生成面板的参考接线', () => { test('图集入口不呈现参考选择,只提交纯提示词', async () => { const user = userEvent.setup(); - const onSubmit = - vi.fn<(input: ResourceCanvasAssetGenerationSubmitInput) => void>(); + const onSubmit = vi.fn< + [input: ResourceCanvasAssetGenerationSubmitInput], + void + >(); render( { test('超限时提交被挡住并给出原因', async () => { const user = userEvent.setup(); - const onSubmit = - vi.fn<(input: ResourceCanvasAssetGenerationSubmitInput) => void>(); + const onSubmit = vi.fn< + [input: ResourceCanvasAssetGenerationSubmitInput], + void + >(); render( cssLengthToPx(term)) .reduce((total, term) => total + term, 0); @@ -270,9 +270,12 @@ describe('「生成任务」侧栏样式', () => { */ const titlebarMinHeight = cssLengthToPx( declaration( - resolveDeclarations(globalWidthRules, [ - '.game-resource-book-scene-titlebar', - ]), + resolveDeclarations( + globalWidthRules, + ['.game-resource-book-scene-titlebar'], + // 标题栏 min-height 不在宽度媒体查询里,取与本套件其余断言一致的桌面宽度。 + 1280, + ), 'min-height', ), ); diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasBottomToolbar.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasBottomToolbar.test.tsx index cb7c5f6f2..04ef0a349 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasBottomToolbar.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasBottomToolbar.test.tsx @@ -8,6 +8,7 @@ import { } from '@testing-library/react'; import { afterEach, describe, expect, test, vi } from 'vitest'; +import type { GameCreationAppAssetManifestEntry } from '../../../packages/shared/src/contracts/gameCreationApp'; import { EDITOR_IMAGE_DIMENSION_OPTIONS, IMAGE_MODEL_NANOBANANA2, @@ -197,7 +198,10 @@ describe('resourceCanvasBottomToolbarModel', () => { }); test('权威规范图判据与 Rust 同口径:精确落点 + icon-spec + 画布来源', () => { - const canvasIconSpec = { + const canvasIconSpec: Pick< + GameCreationAppAssetManifestEntry, + 'kind' | 'localPath' | 'mediaType' | 'source' + > = { kind: 'icon-spec', localPath: RESOURCE_CANVAS_ICON_SPEC_LOCAL_PATH, mediaType: 'image/png', diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasCardName.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasCardName.test.tsx index dad22664f..06a934d0c 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasCardName.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasCardName.test.tsx @@ -22,8 +22,9 @@ vi.mock('@tauri-apps/api/core', async () => ({ ...(await vi.importActual( '@tauri-apps/api/core', )), + // `core` / `invoke` 在全局声明里都是可选:这里断言它们存在。 invoke: (command: string, args?: Record) => - window.__TAURI__!.core.invoke(command, args), + window.__TAURI__!.core!.invoke!(command, args), })); const PROJECT_PATH = '/tmp/workbench-card-name'; @@ -58,7 +59,9 @@ function createCardNameManifest(): GameCreationAppManifest { }, { id: 'asset-notes', - kind: 'design-document', + // 旧夹具用非法 kind 'design-document' 表达「无法归类」;canonical 词表里 + // 与之等价的是 unknown(同样派生到「待归类」),mediaType 仍让它走文档卡。 + kind: 'unknown', mediaType: 'text/markdown', localPath: 'memory/设计文档.md', source: { kind: 'generated' }, @@ -228,7 +231,7 @@ function renderWorkbench(manifest: GameCreationAppManifest) { attachments: [], recentRunStatus: null, recentRunStopReason: null, - supervisor: React.createElement('div', null, '项目总控'), + chat: React.createElement('div', null, '项目总控'), onHomeOpen: vi.fn(), onProjectsOpen: vi.fn(), }), diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasChatReferenceDrop.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasChatReferenceDrop.test.tsx index 446f721ee..f0540b325 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasChatReferenceDrop.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasChatReferenceDrop.test.tsx @@ -11,6 +11,7 @@ import type { import { type ChatReference, RESOURCE_REFERENCE_INSERT_MANY_EVENT, + type ResourceReference, type ResourceReferenceInsertManyEventDetail, } from '../src/features/project-workspace/resourceReferences'; import ProjectDevelopmentView from '../src/view/project-development'; @@ -199,7 +200,7 @@ async function mountCanvas() { attachments: [], recentRunStatus: null, recentRunStopReason: null, - supervisor: React.createElement('div', null, '项目总控'), + chat: React.createElement('div', null, '项目总控'), onHomeOpen: vi.fn(), onProjectsOpen: vi.fn(), }), @@ -316,7 +317,14 @@ describe('拖动素材到对话:批量 @ 引用', () => { expect(inserts).toHaveLength(1); expect( - new Set(inserts[0]!.map((reference) => reference.resourceId)), + new Set( + inserts[0]! + .filter( + (reference): reference is ResourceReference => + reference.type === 'resource', + ) + .map((reference) => reference.resourceId), + ), ).toEqual(new Set(['drop-a', 'drop-b'])); expect(tauri.layoutWrites).toHaveLength(writesBefore); dispose(); diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasFilterModel.test.ts b/apps/ai-game-creator-shell/tests/resourceCanvasFilterModel.test.ts index b8102325e..f6eab39e7 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasFilterModel.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceCanvasFilterModel.test.ts @@ -82,7 +82,7 @@ describe('资源画布筛选模型', () => { 'unclassified', 'version', ]); - expect(RESOURCE_FILTER_REGION_OPTIONS[0].label).toBe('全部区域'); + expect(RESOURCE_FILTER_REGION_OPTIONS[0]!.label).toBe('全部区域'); }); it('区域显示名沿用 @ 面板那份唯一中文口径,并为项目版本单列', () => { @@ -257,23 +257,14 @@ describe('资源画布筛选模型', () => { }); it('筛选生效判据只看关键词与标签:区域只是画布当前栏目,不计入高亮', () => { - expect( - resourceFilterIsActive({ region: 'all', tags: [], keyword: '' }), - ).toBe(false); + expect(resourceFilterIsActive({ tags: [], keyword: '' })).toBe(false); // 纯空白关键词等同没有条件,否则按钮会为一次空输入长期高亮。 - expect( - resourceFilterIsActive({ region: 'all', tags: [], keyword: ' ' }), - ).toBe(false); + expect(resourceFilterIsActive({ tags: [], keyword: ' ' })).toBe(false); // 区域切到别的栏目不等于「有筛选」:用户滚轮换栏时按钮不该常亮。 - expect( - resourceFilterIsActive({ region: 'audio', tags: [], keyword: '' }), - ).toBe(false); - expect( - resourceFilterIsActive({ region: 'all', tags: ['主页'], keyword: '' }), - ).toBe(true); - expect( - resourceFilterIsActive({ region: 'all', tags: [], keyword: '按钮' }), - ).toBe(true); + // 现行签名只接收 tags/keyword,区域不在判据入参内。 + expect(resourceFilterIsActive({ tags: [], keyword: '' })).toBe(false); + expect(resourceFilterIsActive({ tags: ['主页'], keyword: '' })).toBe(true); + expect(resourceFilterIsActive({ tags: [], keyword: '按钮' })).toBe(true); }); it('标签库只统计传入区域内的资源,按使用次数降序、同次数按标签升序', () => { diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx index ce3da42c4..64872ff66 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx @@ -38,7 +38,7 @@ const RESOURCE_FOCUS_SOURCE_LAYER: CanvasLayer = { originalWidth: 1024, originalHeight: 1024, zIndex: 1, - sourceType: 'upload', + sourceType: 'uploaded', }; describe('resourceCanvasFocusModel', () => { diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasGenerationFloatingPanelChrome.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationFloatingPanelChrome.test.tsx index e6608e7f6..46d5c29c3 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasGenerationFloatingPanelChrome.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationFloatingPanelChrome.test.tsx @@ -7,7 +7,10 @@ import { afterEach, describe, expect, test, vi } from 'vitest'; import { ResourceCanvasAssetGenerationPanelView } from '../src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView'; import type { ResourceCanvasAssetToolAction } from '../src/features/resource-canvas/resourceCanvasBottomToolbarModel'; -import { ResourceCanvasGenerationPanelView } from '../src/features/resource-canvas/ResourceCanvasGenerationPanelView'; +import { + ResourceCanvasGenerationPanelView, + type ResourceCanvasGenerationSubmitInput, +} from '../src/features/resource-canvas/ResourceCanvasGenerationPanelView'; import { RESOURCE_CANVAS_GENERATION_PANEL_MAX_WIDTH, RESOURCE_CANVAS_GENERATION_PANEL_VIEWPORT_INSET, @@ -62,7 +65,10 @@ describe('生成浮层的面板外观与高度合同', () => { test('音频入口的浮层同样带 chrome 类,并复用宿主给的提交身份重试', async () => { const user = userEvent.setup(); - const onSubmit = vi.fn(async () => undefined); + // 显式泛型:否则 mock.calls 被推成空元组,下面取提交入参会报越界。 + const onSubmit = vi.fn<[input: ResourceCanvasGenerationSubmitInput], void>( + async () => undefined, + ); render( Supervisor
} + chat={null} onHomeOpen={() => undefined} onProjectsOpen={() => undefined} onManifestChange={(_path, nextManifest) => setManifest(nextManifest)} @@ -357,7 +357,7 @@ function HostWorkbenchWithReferenceRemoval({ attachments={[]} recentRunStatus={null} recentRunStopReason={null} - supervisor={
Supervisor
} + chat={null} onHomeOpen={() => undefined} onProjectsOpen={() => undefined} onManifestChange={(_path, nextManifest) => setManifest(nextManifest)} diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasGenerationLanding.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationLanding.test.tsx index 5c5b7bcb8..12091df8e 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasGenerationLanding.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationLanding.test.tsx @@ -281,7 +281,8 @@ function Workbench({ attachments={[]} recentRunStatus={null} recentRunStopReason={null} - supervisor={
Supervisor
} + // ProjectDevelopmentViewProps 现在必填 chat;本用例只验资源落点,不需要对话区。 + chat={null} onHomeOpen={() => undefined} onProjectsOpen={() => undefined} // 收尾时宿主会重读权威清单:把它接进状态,新素材才会进资源投影(落点的前提)。 diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasGenerationTasksSidebarDismiss.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationTasksSidebarDismiss.test.tsx index b6c627d0b..54154fcfc 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasGenerationTasksSidebarDismiss.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationTasksSidebarDismiss.test.tsx @@ -61,7 +61,8 @@ function installInvoke() { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + // 该 mock 按命令返回固定结构,无法满足 TauriInvoke 的泛型逐命令建模,故断言为 window.__TAURI__。 + window.__TAURI__ = { core: { invoke } } as unknown as typeof window.__TAURI__; } async function renderWorkbench(projectId: string) { @@ -188,7 +189,7 @@ describe('「生成任务」侧栏的开合与入口位置', () => { attachments: [], recentRunStatus: null, recentRunStopReason: null, - supervisor: React.createElement('div', null, '项目总控'), + chat: React.createElement('div', null, '项目总控'), onHomeOpen: vi.fn(), onProjectsOpen: vi.fn(), }), diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasLayoutModel.test.ts b/apps/ai-game-creator-shell/tests/resourceCanvasLayoutModel.test.ts index e49d9ee46..1310098b7 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasLayoutModel.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceCanvasLayoutModel.test.ts @@ -4,6 +4,7 @@ import { GAME_CREATION_RESOURCE_LAYOUT_MAX_COORDINATE, GAME_CREATION_RESOURCE_LAYOUT_MIN_COORDINATE, isSafeProjectResourceCanvasCoordinate, + type ProjectResourceCanvasPosition, } from '../../../packages/shared/src/contracts/gameCreationApp'; import { createEmptyResourceCanvasLayout, diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasManualLayout.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasManualLayout.test.tsx index d9edbb541..70f6ab687 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasManualLayout.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasManualLayout.test.tsx @@ -4,6 +4,7 @@ import { act, renderHook, waitFor } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import type { + GameCreationAppAssetKind, GameCreationAppAssetManifestEntry, GameCreationAppManifest, ProjectResourceCanvasCategory, @@ -41,6 +42,15 @@ const NEW_RESOURCE_ID = `asset:${NEW_ASSET_ID}`; type AssetFixture = GameCreationAppAssetManifestEntry; +/** + * 历史夹具沿用旧 kind `game-rules`,它已不在当前 `GameCreationAppAssetKind` 联合里; + * 用例依赖「未识别 kind」的运行时语义,因此保留原字符串,只在类型层收口。 + */ +function legacyAssetKind(kind: string): GameCreationAppAssetKind { + // 旧 kind 字面量与当前联合结构上不可兼容,这里显式转换以保留原始串的运行时语义。 + return kind as unknown as GameCreationAppAssetKind; +} + function pngAsset(id: string, fileName: string): AssetFixture { return { id, @@ -55,7 +65,7 @@ function pngAsset(id: string, fileName: string): AssetFixture { function markdownAsset(id: string, fileName: string): AssetFixture { return { id, - kind: 'game-rules', + kind: legacyAssetKind('game-rules'), mediaType: 'text/markdown', localPath: `docs/${fileName}`, source: { kind: 'generated', resourceId: `${id}-resource` }, @@ -1148,7 +1158,9 @@ describe('资源画布手动重排口径', () => { // 位置:整理画布仍在排序组左侧,不会被读成排列方式的一部分。 const rowButtons = Array.from(actionsRow!.querySelectorAll('button')); expect(rowButtons.at(-1)).not.toBe(rederiveButton); - const rederiveIndex = rowButtons.indexOf(rederiveButton); + const rederiveIndex = rowButtons.findIndex( + (button) => button === rederiveButton, + ); const sortGroupIndex = rowButtons.findIndex((button) => sortGroup.contains(button), ); diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasQuickEditDraft.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasQuickEditDraft.test.tsx index c98db582b..bc1605d71 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasQuickEditDraft.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasQuickEditDraft.test.tsx @@ -22,7 +22,7 @@ vi.mock('@tauri-apps/api/core', async () => ({ '@tauri-apps/api/core', )), invoke: (command: string, args?: Record) => - window.__TAURI__!.core.invoke(command, args), + window.__TAURI__!.core!.invoke!(command, args), })); const PROJECT_PATH = '/tmp/quick-edit-draft'; @@ -43,21 +43,21 @@ function createDraftManifest(): GameCreationAppManifest { manifest.assets = [ { id: 'source-art', - kind: 'art-image', + kind: 'image', mediaType: 'image/png', localPath: 'assets/source-art.png', source: { kind: 'generated', resourceId: 'art-resource' }, }, { id: 'source-art-2', - kind: 'art-image', + kind: 'image', mediaType: 'image/png', localPath: 'assets/source-art-2.png', source: { kind: 'generated', resourceId: 'art-resource-2' }, }, { id: 'source-rules', - kind: 'game-rules', + kind: 'document', mediaType: 'text/markdown', localPath: 'docs/rules.md', source: { kind: 'generated', resourceId: 'rules-resource' }, @@ -81,7 +81,7 @@ function createCanvasTaskArtManifest(): GameCreationAppManifest { manifest.assets = [ { id: 'source-rules', - kind: 'game-rules', + kind: 'document', mediaType: 'text/markdown', localPath: 'docs/rules.md', source: { kind: 'generated', resourceId: 'rules-resource' }, @@ -214,7 +214,7 @@ function installInvoke( normalizeCalls.push(input); const asset = { id: 'normalized-task-art', - kind: 'art-image', + kind: 'image' as const, mediaType: 'image/png', localPath: CANVAS_TASK_ART_PATH, source: { @@ -246,7 +246,7 @@ function installInvoke( const assetId = `edit-${operationId}`; const asset = { id: assetId, - kind: 'art-image', + kind: 'image' as const, mediaType: 'image/png', localPath: `assets/edits/${operationId}-art.png`, source: { @@ -290,7 +290,7 @@ function DraftWorkbench({ * 触发的正规化无关——这正是「别的流程换了卡片 id」的那条旁路。 */ externalAssetPath?: string | null; -} = {}) { +}) { const [manifest, setManifest] = React.useState(createManifest); return React.createElement( React.Fragment, @@ -307,7 +307,7 @@ function DraftWorkbench({ ...current.assets, { id: 'external-art', - kind: 'art-image', + kind: 'image', mediaType: 'image/png', localPath: externalAssetPath, source: { @@ -328,7 +328,7 @@ function DraftWorkbench({ attachments: [], recentRunStatus: null, recentRunStopReason: null, - supervisor: React.createElement('div', null, '项目总控'), + chat: React.createElement('div', null, '项目总控'), onHomeOpen: vi.fn(), onProjectsOpen: vi.fn(), onManifestChange: ( diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasQuickEditModel.test.ts b/apps/ai-game-creator-shell/tests/resourceCanvasQuickEditModel.test.ts index 4f9c6caf8..41f088ce0 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasQuickEditModel.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceCanvasQuickEditModel.test.ts @@ -26,13 +26,15 @@ const SOURCE_LAYER: CanvasLayer = { originalWidth: 1024, originalHeight: 1024, zIndex: 1, - sourceType: 'upload', + // 现行 CanvasSourceType 只认 'uploaded' / 'generated' / 'mock_generated'。 + sourceType: 'uploaded', }; const RULES_REFERENCE: ChatReference = { type: 'resource', resourceId: 'rules-resource', - kind: 'game-rules', + // 旧的 `game-rules` kind 已不在当前联合里,夹具按真实类型收敛到最接近的 document。 + kind: 'document', mediaType: 'text/markdown', label: 'rules', category: 'document', diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasToolbarModel.test.ts b/apps/ai-game-creator-shell/tests/resourceCanvasToolbarModel.test.ts index 14d349f43..532e238d9 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasToolbarModel.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceCanvasToolbarModel.test.ts @@ -198,7 +198,7 @@ describe('resource canvas toolbar actions', () => { completedManifest, createResource({ manifestAssetId: null, - producerTaskId: completedManifest.tasks[0].id, + producerTaskId: completedManifest.tasks[0]!.id, }), ), ).toEqual(['quick-edit', 'download']); @@ -212,7 +212,7 @@ describe('resource canvas toolbar actions', () => { runningManifest, createResource({ manifestAssetId: null, - producerTaskId: runningManifest.tasks[0].id, + producerTaskId: runningManifest.tasks[0]!.id, }), ), ).toEqual(['download']); diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasVersionBindingModel.test.ts b/apps/ai-game-creator-shell/tests/resourceCanvasVersionBindingModel.test.ts index eff1f4445..8468fe223 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasVersionBindingModel.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceCanvasVersionBindingModel.test.ts @@ -86,10 +86,10 @@ describe('运行模块版本绑定判定', () => { }); test('labels a version with its Chinese created reason and creation time', () => { - expect(formatIterationVersionLabel(versions[0])).toBe( + expect(formatIterationVersionLabel(versions[0]!)).toBe( `初始版本 · ${new Date(1_788_075_047 * 1000).toLocaleString('zh-CN')}`, ); - expect(formatIterationVersionLabel(versions[1])).toBe( + expect(formatIterationVersionLabel(versions[1]!)).toBe( `智能体修订 · ${new Date(1_788_075_104 * 1000).toLocaleString('zh-CN')}`, ); }); @@ -100,7 +100,7 @@ describe('运行模块版本绑定判定', () => { const createdAtLocal = new Date(1_788_075_047 * 1000).toLocaleString( 'zh-CN', ); - const label = formatIterationVersionLabel(versions[0]); + const label = formatIterationVersionLabel(versions[0]!); expect(label).toBe(`初始版本 · ${createdAtLocal}`); // 年 / 月 / 日必须来自真实时刻,而不是 1970。这里用显式数值选项断言, diff --git a/apps/ai-game-creator-shell/tests/resourceCardPreviewAlphaBackground.test.tsx b/apps/ai-game-creator-shell/tests/resourceCardPreviewAlphaBackground.test.tsx index f1a0ccc52..5c33b7045 100644 --- a/apps/ai-game-creator-shell/tests/resourceCardPreviewAlphaBackground.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCardPreviewAlphaBackground.test.tsx @@ -153,7 +153,7 @@ function createFixtureManifest(): GameCreationAppManifest { mediaType: string, ) => ({ id, - kind: 'character', + kind: 'character' as const, category: 'character' as const, mediaType, localPath, diff --git a/apps/ai-game-creator-shell/tests/resourceCardPreviewRealManifest.test.ts b/apps/ai-game-creator-shell/tests/resourceCardPreviewRealManifest.test.ts index abf5030af..c7285b08d 100644 --- a/apps/ai-game-creator-shell/tests/resourceCardPreviewRealManifest.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceCardPreviewRealManifest.test.ts @@ -11,6 +11,7 @@ import { describe, expect, test } from 'vitest'; import { gameCreationAppAssetCategory, + type GameCreationAppAssetKind, type GameCreationAppAssetManifestEntry, } from '../../../packages/shared/src/contracts/gameCreationApp'; import { projectResourceCardPreviewKind } from '../src/view/project-development/resourceCardPreviewModel'; @@ -74,6 +75,9 @@ const REAL_ASSET_SHAPES: readonly Shape[] = [ }, ]; +/** 真机磁盘上遗留的 raw kind:不在 canonical 词表内,故意保留原始串。 */ +const REAL_FUTURE_UI_KIND: string = 'future-ui'; + function buildRealAssets(): GameCreationAppAssetManifestEntry[] { const assets: GameCreationAppAssetManifestEntry[] = []; let index = 0; @@ -82,7 +86,9 @@ function buildRealAssets(): GameCreationAppAssetManifestEntry[] { index += 1; assets.push({ id: `asset-${index}`, - kind: shape.kind, + // 夹具要复现真机磁盘上的非法 kind 串(解析后收口成 unknown), + // 故这里从 string 收窄到 canonical 词表类型,只走类型层。 + kind: shape.kind as GameCreationAppAssetKind, mediaType: shape.mediaType, localPath: `assets/probe-${index}.${shape.ext}`, source: { kind: 'generated' }, @@ -120,7 +126,9 @@ describe('真机 manifest 取证:占位卡片计数与栏目分布', () => { test('存量 57 条无法解析的 kind 的落盘 unclassified 停在「待归类」', () => { const assets = realManifest().assets; - const uiAssets = assets.filter((asset) => asset.kind === 'future-ui'); + const uiAssets = assets.filter( + (asset) => asset.kind === REAL_FUTURE_UI_KIND, + ); // 前置事实:这 57 条落盘值确实是 unclassified(历史误判现场)。 expect(uiAssets).toHaveLength(57); @@ -156,9 +164,14 @@ describe('真机 manifest 取证:占位卡片计数与栏目分布', () => { }), ).toBe('ui-interaction'); // 无法解析的输入统一收口为 unknown → unclassified。 - for (const raw of ['UI', 'future-kind', 'future-ui']) { + // raw 显式声明为 string:夹具要喂 canonical 词表之外的串,从 string 收窄只走类型层。 + const rawKinds: string[] = ['UI', 'future-kind', 'future-ui']; + for (const raw of rawKinds) { expect( - gameCreationAppAssetCategory({ kind: raw, category: 'unclassified' }), + gameCreationAppAssetCategory({ + kind: raw as GameCreationAppAssetKind, + category: 'unclassified', + }), ).toBe('unclassified'); } }); @@ -166,7 +179,12 @@ describe('真机 manifest 取证:占位卡片计数与栏目分布', () => { test('落盘 unclassified 且 kind 派生也是 unclassified 时不受影响', () => { // image / video / code 的 canonical 分类本来就是 unclassified, // 这类资产必须继续信任落盘值,不被重派生规则误伤。 - for (const kind of ['image', 'video', 'code', 'publication-material']) { + for (const kind of [ + 'image', + 'video', + 'code', + 'publication-material', + ] as const) { expect( gameCreationAppAssetCategory({ kind, category: 'unclassified' }), ).toBe('unclassified'); diff --git a/apps/ai-game-creator-shell/tests/resourceCocosPreviewContract.test.tsx b/apps/ai-game-creator-shell/tests/resourceCocosPreviewContract.test.tsx index 81f031887..8e2596d03 100644 --- a/apps/ai-game-creator-shell/tests/resourceCocosPreviewContract.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCocosPreviewContract.test.tsx @@ -3,6 +3,8 @@ import { render, screen, waitFor } from '@testing-library/react'; import type { ReactNode } from 'react'; import { describe, expect, it, vi } from 'vitest'; +import type { GameCreationAppAssetKind } from '../../../packages/shared/src/contracts/gameCreationApp'; + // 浮层外壳(portal + 焦点陷阱)不在本用例关注面内:与文档预览浮层的用例同口径只渲染子节点。 vi.mock('../src/components/modal/ThemedModal', () => ({ ThemedModal: ({ @@ -40,7 +42,7 @@ import { type EngineCase = { path: string; mediaType: string; - kind: string; + kind: GameCreationAppAssetKind; projectedKind: 'art' | 'audio' | 'code' | 'document'; previewKind: | 'model' @@ -234,15 +236,43 @@ const ENGINE_CASES: EngineCase[] = [ }, ]; +/** + * 预览分支只读 `path` / `mediaType` / `subtype`,但预览模型的入参是完整的 + * `ProjectResource`;这里补齐投影产物的其余必填字段,构造与被测链路同形状的对象。 + */ +function resourceFixture(input: { + id: string; + path: string; + mediaType: string; + subtype: string; +}): ProjectResource { + return { + id: input.id, + category: 'unclassified', + subtype: input.subtype, + label: input.path.split('/').pop() ?? input.path, + path: input.path, + mediaType: input.mediaType, + sourceLabel: '', + taskTitle: null, + manifestAssetId: null, + producerTaskId: null, + externalResourceId: null, + referenceResourceIds: [], + dependencies: [], + dependencyDepth: 0, + }; +} + describe('Cocos 资源在资源画布上的准入与预览契约', () => { it('每个引擎资源都能进画布、落到预期卡面分支与类型角标', () => { for (const entry of ENGINE_CASES) { - const resource = { + const resource = resourceFixture({ id: entry.path, path: entry.path, mediaType: entry.mediaType, subtype: entry.kind, - }; + }); expect( projectedResourceKind({ path: entry.path, @@ -266,20 +296,24 @@ describe('Cocos 资源在资源画布上的准入与预览契约', () => { it('模型走 model 读取分支、图片容器走 art,二进制容器不读字节', () => { expect( - projectResourceMediaPreviewCategory({ - id: 'glb', - path: 'assets/model/hero.glb', - mediaType: 'model/gltf-binary', - subtype: 'scene', - }), + projectResourceMediaPreviewCategory( + resourceFixture({ + id: 'glb', + path: 'assets/model/hero.glb', + mediaType: 'model/gltf-binary', + subtype: 'scene', + }), + ), ).toBe('model'); expect( - projectResourceMediaPreviewCategory({ - id: 'tga', - path: 'assets/tex/grass.tga', - mediaType: 'image/x-tga', - subtype: 'image', - }), + projectResourceMediaPreviewCategory( + resourceFixture({ + id: 'tga', + path: 'assets/tex/grass.tga', + mediaType: 'image/x-tga', + subtype: 'image', + }), + ), ).toBe('art'); }); }); diff --git a/apps/ai-game-creator-shell/tests/resourceFilterPanel.test.tsx b/apps/ai-game-creator-shell/tests/resourceFilterPanel.test.tsx index 66107fa25..a8a214249 100644 --- a/apps/ai-game-creator-shell/tests/resourceFilterPanel.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceFilterPanel.test.tsx @@ -4,16 +4,17 @@ import { fireEvent, render, screen } from '@testing-library/react'; import { createRef } from 'react'; import { describe, expect, it, vi } from 'vitest'; +import type { ResourceFilterRegion } from '../src/view/project-development/resourceCanvasFilterModel'; import { ResourceFilterPanel } from '../src/view/project-development/ResourceFilterPanel'; function renderPanel( overrides: { keyword?: string; - region?: 'all' | 'ui-interaction' | 'character'; + region?: ResourceFilterRegion; activeTags?: string[]; tagItems?: Array<{ tag: string; assetCount: number }>; onKeywordChange?: (value: string) => void; - onRegionChange?: (region: string) => void; + onRegionChange?: (region: ResourceFilterRegion) => void; onToggleTag?: (tag: string) => void; onClose?: () => void; } = {}, @@ -35,7 +36,7 @@ function renderPanel( keyword={overrides.keyword ?? ''} onKeywordChange={handlers.onKeywordChange} region={overrides.region ?? 'all'} - onRegionChange={handlers.onRegionChange as (region: 'all') => void} + onRegionChange={handlers.onRegionChange} activeTags={overrides.activeTags ?? []} onToggleTag={handlers.onToggleTag} tagItems={ diff --git a/apps/ai-game-creator-shell/tests/resourceJsonCanvas.test.tsx b/apps/ai-game-creator-shell/tests/resourceJsonCanvas.test.tsx index 36230170a..644cef8e9 100644 --- a/apps/ai-game-creator-shell/tests/resourceJsonCanvas.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceJsonCanvas.test.tsx @@ -1,7 +1,10 @@ /** @vitest-environment jsdom */ import { afterEach, describe, expect, it, vi } from 'vitest'; -import type { GameCreationAppAssetKind } from '../../../packages/shared/src/contracts/gameCreationApp'; +import type { + GameCreationAppAssetKind, + GameCreationAppAssetManifestEntry, +} from '../../../packages/shared/src/contracts/gameCreationApp'; import { createGameCreationAppManifest, fireEvent, @@ -19,7 +22,7 @@ vi.mock('@tauri-apps/api/core', async () => ({ '@tauri-apps/api/core', )), invoke: (command: string, args?: Record) => - window.__TAURI__!.core.invoke(command, args), + window.__TAURI__!.core!.invoke!(command, args), })); const projectId = 'json-canvas'; @@ -37,11 +40,14 @@ async function mountJsonCanvas( ) { installResizeObserverStub(); const manifest = createGameCreationAppManifest(projectId, 'JSON 资源测试'); - manifest.assets = [ + const baseAssets: Array< + Pick + > = [ { id: 'design', kind, localPath: 'ui/design.json' }, { id: 'ordinary', kind: 'unknown', localPath: 'assets/ordinary.json' }, { id: 'spoof', kind: 'unknown', localPath: 'ui/spoof.json' }, - ].map((asset) => ({ + ]; + manifest.assets = baseAssets.map((asset) => ({ ...asset, mediaType: 'application/json', category: 'document' as const, @@ -129,6 +135,8 @@ async function mountJsonCanvas( recentRunStatus={null} recentRunStopReason={null} chat={
对话
} + onHomeOpen={() => {}} + onProjectsOpen={() => {}} onManifestChange={onManifestChange} />, ); diff --git a/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx b/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx index d07631abb..321e33e9c 100644 --- a/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx @@ -22,6 +22,7 @@ import { import { afterEach, describe, expect, test, vi } from 'vitest'; import type { + GameCreationAppAssetKind, GameCreationAppAssetManifestEntry, GameIterationVersion, } from '../../../packages/shared/src/contracts/gameCreationApp'; @@ -129,7 +130,7 @@ defineResizeObserverShim(); function asset( id: string, - kind: string, + kind: GameCreationAppAssetKind, mediaType: string, localPath: string, ): GameCreationAppAssetManifestEntry { @@ -694,7 +695,7 @@ describe('ResourceReferenceInput', () => { }); test('AI 润色只换文本时不会把引用一轮轮追加成重复 @显示名', async () => { - const onChange = vi.fn<(draft: ChatComposerDraft) => void>(); + const onChange = vi.fn<[draft: ChatComposerDraft], void>(); const reference = resourceReferenceFromAsset(assets[0]!, 'asset-picker'); function Controlled() { const composerRef = createRef(); @@ -739,7 +740,7 @@ describe('ResourceReferenceInput', () => { }); test('引用后面的段落分隔原样落进 content:读回的 prompt 与编辑器分段逐字一致', async () => { - const onChange = vi.fn<(draft: ChatComposerDraft) => void>(); + const onChange = vi.fn<[draft: ChatComposerDraft], void>(); const reference = resourceReferenceFromAsset(assets[0]!, 'asset-picker'); function Controlled() { const composerRef = createRef(); @@ -785,7 +786,7 @@ describe('ResourceReferenceInput', () => { }); test('润色改写掉 `@附件名` token 时,附件 part 补在末尾而不是丢掉', async () => { - const onChange = vi.fn<(draft: ChatComposerDraft) => void>(); + const onChange = vi.fn<[draft: ChatComposerDraft], void>(); const reference = resourceReferenceFromAsset(assets[0]!, 'asset-picker'); const attachment = { type: 'agc_attachment_reference' as const, @@ -828,7 +829,7 @@ describe('ResourceReferenceInput', () => { }); test('润色回包还留着 `@名称` token 时,引用与附件都在原位换回真 part', async () => { - const onChange = vi.fn<(draft: ChatComposerDraft) => void>(); + const onChange = vi.fn<[draft: ChatComposerDraft], void>(); const reference = resourceReferenceFromAsset(assets[0]!, 'asset-picker'); const attachment = { type: 'agc_attachment_reference' as const, @@ -878,7 +879,7 @@ describe('ResourceReferenceInput', () => { }); test('润色回写时 provider 答不出 token 的 part 仍留在草稿里,不在这门翻译里消失', async () => { - const onChange = vi.fn<(draft: ChatComposerDraft) => void>(); + const onChange = vi.fn<[draft: ChatComposerDraft], void>(); const reference = resourceReferenceFromAsset(assets[0]!, 'asset-picker'); // provider 契约被破坏:解析得出引用,但答不出 token。它仍然必须活过这一轮润色回写。 const tokenlessProvider: ReferenceProvider = { @@ -982,7 +983,7 @@ describe('ResourceReferenceInput', () => { test('opens the asset picker, supports multi-select, and inserts stable references', async () => { const user = userEvent.setup(); - const onChange = vi.fn<(draft: ChatComposerDraft) => void>(); + const onChange = vi.fn<[draft: ChatComposerDraft], void>(); render( { }); test('一次 insertReferences 按给定顺序插入整批 chip', async () => { - const onChange = vi.fn<(draft: ChatComposerDraft) => void>(); + const onChange = vi.fn<[draft: ChatComposerDraft], void>(); const composerRef = createRef(); render( { test('removing a chip keeps the remaining text editable', async () => { const user = userEvent.setup(); - const onChange = vi.fn<(draft: ChatComposerDraft) => void>(); + const onChange = vi.fn<[draft: ChatComposerDraft], void>(); render( { test('keeps the whole reference after one delete and drops its data in one step', async () => { const user = userEvent.setup(); - const onChange = vi.fn<(draft: ChatComposerDraft) => void>(); + const onChange = vi.fn<[draft: ChatComposerDraft], void>(); render( { test('refreshes chip and candidate display names after a resource rename', async () => { const user = userEvent.setup(); - const onChange = vi.fn<(draft: ChatComposerDraft) => void>(); + const onChange = vi.fn<[draft: ChatComposerDraft], void>(); const composerRef = createRef(); const renamedAssets = [ asset('hero', 'character', 'image/png', 'assets/hero-final.png'), @@ -1513,7 +1514,7 @@ describe('ResourceReferenceInput', () => { test('restores a cross-session draft with the caret at the end of the text', async () => { const user = userEvent.setup(); - const onChange = vi.fn<(draft: ChatComposerDraft) => void>(); + const onChange = vi.fn<[draft: ChatComposerDraft], void>(); const composerRef = createRef(); render( { }); test('聊天与快速编辑提示词插入同一素材:草稿文本与引用完全一致', async () => { - const chatOnChange = vi.fn<(draft: ChatComposerDraft) => void>(); - const quickEditOnChange = vi.fn<(draft: ChatComposerDraft) => void>(); + const chatOnChange = vi.fn<[draft: ChatComposerDraft], void>(); + const quickEditOnChange = vi.fn<[draft: ChatComposerDraft], void>(); render( <> ({ + versions: manifest.versions!.map((version) => ({ ...version, resourceBindings: [ { slotId: 'asset:asset-final', resourceId: 'asset-final' }, @@ -356,7 +356,7 @@ function renderReplacementWorkbench(options: RenderOptions = {}) { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + window.__TAURI__ = { core: { invoke: invoke as never } }; const onActiveVersionChange = vi.fn(); const onPlay = vi.fn(); diff --git a/apps/ai-game-creator-shell/tests/resourceVersionReplacementModel.test.ts b/apps/ai-game-creator-shell/tests/resourceVersionReplacementModel.test.ts index 2352a8a09..043e3e116 100644 --- a/apps/ai-game-creator-shell/tests/resourceVersionReplacementModel.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceVersionReplacementModel.test.ts @@ -1,7 +1,10 @@ /** @vitest-environment jsdom */ import { describe, expect, it, vi } from 'vitest'; -import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp'; +import type { + GameCreationAppAssetKind, + GameCreationAppManifest, +} from '../../../packages/shared/src/contracts/gameCreationApp'; import type { LocalProjectVersionReplacementCandidate, ProjectVersionResourceCompatibility, @@ -22,6 +25,17 @@ import { } from '../src/features/resource-canvas/resourceVersionReplacementTransport'; import { projectResourcesFromReadModels } from '../src/view/project-development/resourceProjectionModel'; +type TauriInvoke = NonNullable< + NonNullable['core']>['invoke'] +>; + +function asTauriInvoke( + invoke: (command: string, args?: Record) => Promise, +): TauriInvoke { + // 泛型 invoke 与具体的非泛型 mock 结构上不可兼容,只能用 as unknown as 收敛。 + return invoke as unknown as TauriInvoke; +} + function candidate( resourceId: string, compatibility: Partial = {}, @@ -47,7 +61,7 @@ function candidate( function manifestWithAssets( assets: Array<{ id: string; - kind: string; + kind: GameCreationAppAssetKind; mediaType: string; localPath: string; }>, @@ -357,7 +371,7 @@ describe('候选择序与资源映射', () => { describe('资源替换的 IPC 边界', () => { it('两个命令都只传一个 input 对象,字段名与 Rust 结构体逐字一致', async () => { const invoke = vi.fn(async () => ({ ok: true })); - window.__TAURI__ = { core: { invoke } }; + window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke) } }; await readVersionResourceReplacementCandidates({ projectPath: '/tmp/project', diff --git a/apps/ai-game-creator-shell/tests/resourceVersionSwitch.test.tsx b/apps/ai-game-creator-shell/tests/resourceVersionSwitch.test.tsx index 758668eaa..ba5b4bf63 100644 --- a/apps/ai-game-creator-shell/tests/resourceVersionSwitch.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceVersionSwitch.test.tsx @@ -109,6 +109,7 @@ function renderWorkbench( props: { activeVersionId?: string | null; onActiveVersionChange?: (versionId: string) => void; + onPlay?: () => void; } = {}, ) { const { rerender } = render( @@ -321,7 +322,7 @@ describe('C7 运行模块版本切换', () => { try { render( React.createElement(GameRunVersionPicker, { - versions: createVersionedManifest().versions, + versions: createVersionedManifest().versions ?? [], activeVersionId: null, onSelectVersion: vi.fn(), }), @@ -358,7 +359,7 @@ describe('C7 运行模块版本切换', () => { const onSelectVersion = vi.fn(); render( React.createElement(GameRunVersionPicker, { - versions: createVersionedManifest().versions, + versions: createVersionedManifest().versions ?? [], activeVersionId: null, onSelectVersion, }), @@ -389,7 +390,7 @@ describe('C7 运行模块版本切换', () => { test('keeps the portal menu mounted when the click lands on the menu itself', async () => { render( React.createElement(GameRunVersionPicker, { - versions: createVersionedManifest().versions, + versions: createVersionedManifest().versions ?? [], activeVersionId: null, onSelectVersion: vi.fn(), }), diff --git a/apps/ai-game-creator-shell/tests/runPreviewBrowserOpen.test.tsx b/apps/ai-game-creator-shell/tests/runPreviewBrowserOpen.test.tsx index 029445850..3f7044dea 100644 --- a/apps/ai-game-creator-shell/tests/runPreviewBrowserOpen.test.tsx +++ b/apps/ai-game-creator-shell/tests/runPreviewBrowserOpen.test.tsx @@ -5,7 +5,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { createGameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp'; import ProjectDevelopmentView from '../src/view/project-development'; -const openUrl = vi.fn(async () => undefined); +const openUrl = vi.fn(async (_url: string) => undefined); vi.mock('@tauri-apps/plugin-opener', () => ({ openUrl: (url: string) => openUrl(url), })); @@ -61,7 +61,7 @@ function renderRunView(options: { withPreview?: boolean } = {}) { ? null : { status: 'running', url: PREVIEW_URL, port: 4173 } } - supervisor={
项目总控
} + chat={
项目总控
} onHomeOpen={vi.fn()} onProjectsOpen={vi.fn()} onNotice={onNotice} diff --git a/apps/ai-game-creator-shell/tests/runPreviewFullscreen.test.tsx b/apps/ai-game-creator-shell/tests/runPreviewFullscreen.test.tsx index 2b289d564..6a2dc5c60 100644 --- a/apps/ai-game-creator-shell/tests/runPreviewFullscreen.test.tsx +++ b/apps/ai-game-creator-shell/tests/runPreviewFullscreen.test.tsx @@ -51,7 +51,8 @@ function renderRunView() { recentRunStatus={null} recentRunStopReason={null} preview={{ status: 'running', url: PREVIEW_URL, port: 4173 }} - supervisor={
项目总控
} + // ProjectDevelopmentViewProps 现在必填 chat;旧的 supervisor 入参已不存在。 + chat={null} onHomeOpen={vi.fn()} onProjectsOpen={vi.fn()} onNotice={vi.fn()} diff --git a/apps/ai-game-creator-shell/tests/separationOverview.test.ts b/apps/ai-game-creator-shell/tests/separationOverview.test.ts index 4761832ad..ead2bf2cc 100644 --- a/apps/ai-game-creator-shell/tests/separationOverview.test.ts +++ b/apps/ai-game-creator-shell/tests/separationOverview.test.ts @@ -72,7 +72,9 @@ function node( source: 'System', }, component, + children_display_mode: 'Stack', children, + offset: { min: [0, 0], max: [0, 0] }, }; } @@ -157,7 +159,7 @@ describe('separation status writeback', () => { ]), ).toEqual([]); expect( - problematicTree[0].root.children[0]?.metadata.component_status, + problematicTree[0]?.root.children[0]?.metadata.component_status, ).toEqual({ NeedReview: '自动切分重试已达上限(3 次)\n第一次建议\n最后建议', }); diff --git a/apps/ai-game-creator-shell/tests/sessionPreview.test.ts b/apps/ai-game-creator-shell/tests/sessionPreview.test.ts index 558a8a6a4..2443b834c 100644 --- a/apps/ai-game-creator-shell/tests/sessionPreview.test.ts +++ b/apps/ai-game-creator-shell/tests/sessionPreview.test.ts @@ -3,22 +3,41 @@ import { readFileSync } from 'node:fs'; import { describe, expect, it, vi } from 'vitest'; import type { GameCreationAppPreviewState } from '../../../packages/shared/src/contracts/gameCreationApp'; -import { resolveSessionPreviewOnProjectOpen } from '../src/features/app-shell/sessionPreview'; +import { + type PreviewCommandInvoke, + resolveSessionPreviewOnProjectOpen, +} from '../src/features/app-shell/sessionPreview'; import { repoPath } from './repoPath'; const projectPath = '/tmp/session-preview-project'; +type RawPreviewInvoke = ( + command: string, + args?: Record, +) => Promise; + +// 泛型 invoke 与具体的非泛型 mock 结构上不可兼容,只能用 as unknown as 收敛; +// 运行时原样返回同一个 mock,调用方仍持有原引用。 +function asPreviewCommandInvoke( + invoke: RawPreviewInvoke, +): PreviewCommandInvoke { + return invoke as unknown as PreviewCommandInvoke; +} + function createInvoke( responses: Record, calls: Array<[string, Record | undefined]> = [], ) { - return vi.fn(async (command: string, args?: Record) => { - calls.push([command, args]); - if (!(command in responses)) { - throw new Error(`unexpected invoke ${command}`); - } - return responses[command]; - }); + const invoke = vi.fn( + async (command: string, args?: Record) => { + calls.push([command, args]); + if (!(command in responses)) { + throw new Error(`unexpected invoke ${command}`); + } + return responses[command]; + }, + ); + return asPreviewCommandInvoke(invoke); } describe('进入项目时的预览活体核验', () => { @@ -94,14 +113,14 @@ describe('进入项目时的预览活体核验', () => { it('registry 不可读时不顺手停预览:读失败不等于确认没有在跑', async () => { const calls: Array<[string, Record | undefined]> = []; - const invoke = vi.fn( - async (command: string, args?: Record) => { + const invoke = asPreviewCommandInvoke( + vi.fn(async (command: string, args?: Record) => { calls.push([command, args]); if (command === 'get_local_game_preview_status') { throw new Error('需要在陶泥儿客户端内运行'); } return { status: 'stopped', url: null, port: null, root: null }; - }, + }), ); const resolution = await resolveSessionPreviewOnProjectOpen({ diff --git a/apps/ai-game-creator-shell/tests/stageStatusOverview.test.ts b/apps/ai-game-creator-shell/tests/stageStatusOverview.test.ts index b7677ead3..970f7e95f 100644 --- a/apps/ai-game-creator-shell/tests/stageStatusOverview.test.ts +++ b/apps/ai-game-creator-shell/tests/stageStatusOverview.test.ts @@ -40,6 +40,7 @@ function node(id: string, status: StageStatus, children: Node[] = []): Node { component: null, children_display_mode: 'Stack', children, + offset: { min: [0, 0], max: [0, 0] }, }; } diff --git a/apps/ai-game-creator-shell/tests/start-dev-stack.test.ts b/apps/ai-game-creator-shell/tests/start-dev-stack.test.ts index efbd54f1d..20c943336 100644 --- a/apps/ai-game-creator-shell/tests/start-dev-stack.test.ts +++ b/apps/ai-game-creator-shell/tests/start-dev-stack.test.ts @@ -7,23 +7,23 @@ import { fileURLToPath } from 'node:url'; import { describe, expect, test, vi } from 'vitest'; import { - ensureAdminWeb, - ensureBackend, + ensureAdminWeb as ensureAdminWebImpl, + ensureBackend as ensureBackendImpl, formatOwnerLabel, formatStartupSummary, - isBackendReady, + isBackendReady as isBackendReadyImpl, isProcessGroupAlive, isWorktreeApiServerOwner, isWorktreeSpacetimeOwner, - preflightExistingVite, + preflightExistingVite as preflightExistingViteImpl, readAdminWebEnabled, readBackendServiceFailure, - readLinuxProcessGroupAlive, - readWindowsPortOwnerIdentities, + readLinuxProcessGroupAlive as readLinuxProcessGroupAliveImpl, + readWindowsPortOwnerIdentities as readWindowsPortOwnerIdentitiesImpl, resolveBackendTargetsFromState, - runWindowsTaskkill, + runWindowsTaskkill as runWindowsTaskkillImpl, spawnChild, - startAdminWeb, + startAdminWeb as startAdminWebImpl, stopChild, terminateChildTree, verifyAgcBackendOwnership, @@ -31,6 +31,209 @@ import { waitForChildTermination, } from '../scripts/start-dev-stack.mjs'; +// scripts/*.mjs 没有 JSDoc:TS 只能按默认值推导签名,可选回调参数被推成具体实现、 +// 注入点被推成 null/never[],与测试使用的 mock 结构不兼容。下面按运行时真实契约重新 +// 声明局部签名并重绑定导入实现,只做类型层校正,调用方式与运行时行为完全不变。 + +type EndpointPortRange = { start: number; end: number; label: string }; + +type PortOwnerIdentity = { + port?: number; + processId?: number; + name?: string | null; + executablePath?: string | null; + commandLine?: string | null; +}; + +type BackendServiceFixture = { + status: string; + url: string; + repoRoot?: string; + instanceId?: string; + exitCode?: number; + signal?: string | null; +}; + +type BackendStateFixture = { + schemaVersion: number; + repoRoot?: string; + instanceId?: string; + database: string; + updatedAt: string; + spacetimeDataDir?: string; + services: Record; +}; + +type BackendOwnershipResult = { + ok: boolean; + reason: string; + owners?: Map; + apiOwner?: PortOwnerIdentity; + workerOwner?: PortOwnerIdentity; + spacetimeOwner?: PortOwnerIdentity; +}; + +type IsBackendReadyOptions = { + state?: BackendStateFixture; + isReady?: (url: string) => boolean | Promise; + verifyOwnership?: (options?: { + apiUrl?: string; + spacetimeUrl?: string; + bgfilterWorkerUrl?: string; + }) => BackendOwnershipResult | Promise; + onOwnershipRejected?: ((ownership: BackendOwnershipResult) => void) | null; +}; + +// 导入实现本身是同一份,仅按运行时契约修正推导过窄的签名(JS 默认值导致结构上不可兼容)。 +const isBackendReady = isBackendReadyImpl as unknown as ( + options?: IsBackendReadyOptions, +) => Promise; + +type SpawnSyncLike = ( + command: string, + args: readonly string[], + options?: { env?: Record }, +) => { status: number | null; error?: unknown; stdout: string }; + +type ReadWindowsPortOwnersOptions = { + spawnImpl?: SpawnSyncLike; + env?: Record; + now?: () => number; + commandLineTtlMs?: number; + commandLineCache?: Map; +}; + +// 同上:spawnImpl 在 JS 推断里被收窄为 Node 的 spawnSync 重载,测试注入的同步 mock 不可直接赋值。 +const readWindowsPortOwnerIdentities = + readWindowsPortOwnerIdentitiesImpl as unknown as ( + ports: number[], + options?: ReadWindowsPortOwnersOptions, + ) => Map | null; + +type ReadLinuxProcessGroupAliveOptions = { + readdirImpl?: (path: string) => string[]; + readFileImpl?: (path: string, encoding: string) => string; +}; + +// 同上:readdirImpl/readFileImpl 在 JS 推断里被收窄为 readdirSync/readFileSync 的重载。 +const readLinuxProcessGroupAlive = + readLinuxProcessGroupAliveImpl as unknown as ( + processGroupId: number, + options?: ReadLinuxProcessGroupAliveOptions, + ) => boolean | null; + +type ChildLike = EventEmitter & { + pid?: number; + exitCode?: number | null; + signalCode?: string | null; + kill?: (signal?: string | number) => unknown; +}; + +type EnsureBackendOptions = { + onBackendChild?: (child: ChildLike) => void; + checkBackendReady?: () => boolean | Promise; + resolveTargets?: () => unknown; + spawnBackend?: () => ChildLike; + waitUntilReady?: (child: ChildLike) => unknown | Promise; +}; + +// 同上:spawnBackend 在 JS 推断里被收窄为返回完整 ChildProcessWithoutNullStreams。 +const ensureBackend = ensureBackendImpl as unknown as ( + options?: EnsureBackendOptions, +) => Promise<{ backendChild: ChildLike | null; targets: unknown }>; + +type TaskkillChildLike = EventEmitter & { + kill: (signal?: string | number) => unknown; +}; + +type RunWindowsTaskkillOptions = { + spawnImpl?: ( + command: string, + args: readonly string[], + options: { shell: boolean; stdio: string; windowsHide: boolean }, + ) => TaskkillChildLike; + timeoutMs?: number; +}; + +// 同上:spawnImpl 在 JS 推断里被收窄为 Node 的 spawn 重载,测试注入的 EventEmitter mock 不可直接赋值。 +const runWindowsTaskkill = runWindowsTaskkillImpl as unknown as ( + processId: number, + options?: RunWindowsTaskkillOptions, +) => Promise<{ timedOut: boolean; code: number | null; error: unknown }>; + +type PreflightEndpoint = { + host: string; + port: number; + url: string; + markerUrl: string; + portRange: EndpointPortRange | null; +}; + +type PreflightExistingViteOptions = { + endpoint?: PreflightEndpoint; + readServer?: ( + endpoint: PreflightEndpoint, + ) => Promise<{ statusCode: number; body: string } | null>; + portListening?: (endpoint: PreflightEndpoint) => Promise; + readMarker?: (endpoint: PreflightEndpoint) => Promise; +}; + +// 同上:endpoint 在 JS 推断里被收窄为 portRange: null(createAgcDevEndpoint 的默认值)。 +const preflightExistingVite = preflightExistingViteImpl as unknown as ( + options?: PreflightExistingViteOptions, +) => Promise<{ status: string; apiTarget: string }>; + +type AdminWebEndpoint = { + host: string; + port: number; + basePath?: string; + url: string; +}; + +type AdminWebChild = { pid?: number }; + +type StartAdminWebOptions = { + env?: Record; + spawnImpl?: ( + command: string, + args: string[], + options: { cwd: string; env: Record }, + ) => AdminWebChild; +}; + +// 同上:spawnImpl 在 JS 推断里被收窄为 spawnChild 的完整子进程返回类型。 +const startAdminWeb = startAdminWebImpl as unknown as ( + apiUrl: string, + endpoint: AdminWebEndpoint, + options?: StartAdminWebOptions, +) => AdminWebChild; + +type EnsureAdminWebOptions = { + apiUrl: string; + reservedPorts?: number[]; + env?: Record; + enabled?: boolean; + resolveEndpoint?: (options: { + env?: Record; + reservedPorts?: number[]; + }) => AdminWebEndpoint | Promise; + spawnAdminWeb?: ( + apiUrl: string, + endpoint: AdminWebEndpoint, + options: { env?: Record }, + ) => AdminWebChild; + waitForExit?: (child: AdminWebChild) => unknown | Promise; + warn?: (message: string) => void; +}; + +// 同上:apiUrl 等无默认值的解构项未被 JS 推断保留,reservedPorts 被推成 never[]。 +const ensureAdminWeb = ensureAdminWebImpl as unknown as ( + options: EnsureAdminWebOptions, +) => Promise<{ + endpoint: AdminWebEndpoint | null; + child: AdminWebChild | null; +}>; + const expectedDatabase = 'genarrative-game-creator-dev'; const expectedDataDir = resolve('server-rs/.spacetimedb/ai-game-creator/data'); const expectedExePath = resolve('server-rs/target/debug/api-server.exe'); @@ -40,7 +243,10 @@ const ownedBackend = async () => ({ owners: new Map(), }); -function backendState(spacetimeDataDir?: string, includeBgfilterWorker = true) { +function backendState( + spacetimeDataDir?: string, + includeBgfilterWorker = true, +): BackendStateFixture { const instanceId = 'fixture-instance'; const service = (url: string) => ({ status: 'running', @@ -180,9 +386,9 @@ describe('AI 游戏创作配套后端复用门禁', () => { test('后端服务失败时返回具体失败服务,避免外层无限等待', () => { const state = backendState(expectedDataDir); - state.services['bgfilter-worker'].status = 'failed'; - state.services['bgfilter-worker'].exitCode = 1; - state.services['bgfilter-worker'].signal = null; + state.services['bgfilter-worker']!.status = 'failed'; + state.services['bgfilter-worker']!.exitCode = 1; + state.services['bgfilter-worker']!.signal = null; expect(readBackendServiceFailure(state)).toEqual({ serviceName: 'bgfilter-worker', @@ -192,8 +398,8 @@ describe('AI 游戏创作配套后端复用门禁', () => { test('不匹配的旧状态失败记录不会阻断当前后端启动', () => { const state = backendState(resolve('server-rs/.spacetimedb/other/data')); - state.services['bgfilter-worker'].status = 'failed'; - state.services['bgfilter-worker'].exitCode = 1; + state.services['bgfilter-worker']!.status = 'failed'; + state.services['bgfilter-worker']!.exitCode = 1; expect(readBackendServiceFailure(state)).toBeNull(); }); @@ -203,8 +409,8 @@ describe('AI 游戏创作配套后端复用门禁', () => { initialState.updatedAt = '2026-09-04T08:00:00.000Z'; const state = backendState(expectedDataDir); state.updatedAt = '2026-09-04T08:00:01.000Z'; - state.services['bgfilter-worker'].status = 'failed'; - state.services['bgfilter-worker'].exitCode = 98; + state.services['bgfilter-worker']!.status = 'failed'; + state.services['bgfilter-worker']!.exitCode = 98; const child = Object.assign(new EventEmitter(), { exitCode: null, signalCode: null, @@ -426,7 +632,7 @@ describe('AI 游戏创作配套后端归属校验', () => { }); test('端口探测改用 netstat 取监听 PID,不再每次调用 WMI 的 Get-NetTCPConnection', () => { - const spawnImpl = vi.fn((..._args: unknown[]) => ({ + const spawnImpl = vi.fn((_command: string, args: readonly string[]) => ({ status: 0, error: null, stdout: '[]', @@ -434,7 +640,7 @@ describe('AI 游戏创作配套后端归属校验', () => { readWindowsPortOwnerIdentities([8082], { spawnImpl, env: {} }); - const probeArgs = spawnImpl.mock.calls[0][1] ?? []; + const probeArgs = spawnImpl.mock.calls[0]![1] ?? []; const probeScript = String(probeArgs[probeArgs.length - 1] ?? ''); expect(probeScript).toContain('netstat -ano -p tcp'); expect(probeScript).not.toContain('Get-NetTCPConnection'); @@ -470,7 +676,7 @@ describe('AI 游戏创作配套后端归属校验', () => { commandLineTtlMs: 60_000, }; const envAt = (call: number) => - (spawnImpl.mock.calls[call][2] as { env: Record }).env; + (spawnImpl.mock.calls[call]![2] as { env: Record }).env; readWindowsPortOwnerIdentities([3102], options); expect(envAt(0)).toEqual({ GENARRATIVE_QUERY_PORTS: '3102' }); @@ -595,9 +801,10 @@ describe('AI 游戏创作启动子进程生命周期', () => { await waitForFile(stoppedPath); } finally { - if (Number.isInteger(child?.pid)) { + const childPid = child?.pid; + if (Number.isInteger(childPid)) { try { - process.kill(-child.pid, 'SIGKILL'); + process.kill(-childPid!, 'SIGKILL'); } catch { // 测试后代已经退出。 } @@ -747,7 +954,13 @@ describe('AGC 开发态后台 Web', () => { }); test('后台 Vite 指向配套后端且严格使用解析后的端口', () => { - const spawnImpl = vi.fn(() => ({ pid: 4242 })); + const spawnImpl = vi.fn( + ( + _command: string, + _args: string[], + _options: { cwd: string; env: Record }, + ) => ({ pid: 4242 }), + ); startAdminWeb( 'http://127.0.0.1:8084', { @@ -760,7 +973,7 @@ describe('AGC 开发态后台 Web', () => { ); expect(spawnImpl).toHaveBeenCalledTimes(1); - const [command, args, options] = spawnImpl.mock.calls[0]; + const [command, args, options] = spawnImpl.mock.calls[0]!; expect(command).toBe(process.platform === 'win32' ? 'npm.cmd' : 'npm'); expect(args).toEqual([ '--prefix', diff --git a/apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts b/apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts index b3de87823..fc784f551 100644 --- a/apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts +++ b/apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts @@ -7,7 +7,7 @@ import { describe, expect, test, vi } from 'vitest'; import { readCargoTarget, - resolveEditorFeatures, + resolveEditorFeatures as resolveEditorFeaturesImpl, } from '../scripts/cargo-features.mjs'; import { isProcessGroupAlive, @@ -17,11 +17,91 @@ import { import { buildTauriArguments, buildTauriDevProcessEnv, - prepareBundledResourcesBeforeTauri, - runTauriDev as runTauriDevImpl, + prepareBundledResourcesBeforeTauri as prepareBundledResourcesBeforeTauriImpl, + runTauriDev as runTauriDevRaw, withDevCargoFeatures, } from '../scripts/start-tauri-dev.mjs'; +// scripts/*.mjs 没有 JSDoc:TS 只能按默认值推导签名,可选回调参数被推成具体实现、 +// argv 默认值被推成 never[],与测试注入的 mock / 参数结构不兼容。下面按运行时真实契约 +// 重新声明局部签名并重绑定导入实现,只做类型层校正,调用方式与运行时行为完全不变。 + +type EndpointPortRange = { start: number; end: number; label: string }; + +type DevEndpoint = { + host: string; + port: number; + url: string; + markerUrl: string; + portRange: EndpointPortRange | null; +}; + +type ResolveEditorFeaturesOptions = { + argv?: string[]; + target?: string; + env?: Record; +}; + +// 同上:函数的 argv 默认值 [] 被 JS 推断成 never[],无法接收测试传入的 string[]。 +const resolveEditorFeatures = resolveEditorFeaturesImpl as unknown as ( + options?: ResolveEditorFeaturesOptions, +) => string[]; + +type PrepareBundledResourcesOptions = { + target: string; + features: Iterable; +}; + +type PrepareBundledResourcesBeforeTauriOptions = { + prepare?: (options: PrepareBundledResourcesOptions) => unknown[]; + log?: (message: string) => void; + hostTarget?: string | null; +}; + +// 同上:prepare 在 JS 推断里被收窄为 prepareBundledResources 的完整可选参数签名。 +const prepareBundledResourcesBeforeTauri = + prepareBundledResourcesBeforeTauriImpl as unknown as ( + features: Iterable, + argv?: string[], + options?: PrepareBundledResourcesBeforeTauriOptions, + ) => void; + +type ChildProcessLike = { + pid?: number; + exitCode?: number | null; + signalCode?: string | null; +}; + +type RunTauriDevOptions = { + resolveDevEndpoint?: (options?: { + strictConfigured?: boolean; + }) => DevEndpoint | Promise; + preflight?: (options: { + endpoint: DevEndpoint; + }) => unknown | Promise; + prepareFrontend?: ( + endpoint: DevEndpoint, + options: { + signal: AbortSignal; + onChild: (child: ChildProcessLike) => void; + }, + ) => unknown | Promise; + spawnCli?: ( + argv: string[], + options: { env: Record }, + ) => ChildProcessLike; + waitForCli?: (child: ChildProcessLike) => unknown | Promise; + terminateTree?: (child: ChildProcessLike) => unknown | Promise; + prepareResources?: (...args: unknown[]) => void; +}; + +// 同上:resolveDevEndpoint / preflight / spawnCli / terminateTree 在 JS 推断里被收窄为 +// 具体实现的返回结构,测试注入的 mock 不可直接赋值。 +const runTauriDevImpl = runTauriDevRaw as unknown as ( + argv?: string[], + options?: RunTauriDevOptions, +) => Promise; + const testEndpoint = { host: '127.0.0.1', port: 10005, @@ -227,7 +307,7 @@ describe('AI 游戏创作 Tauri dev 生命周期', () => { } }, 10); `; - let cliChild; + let cliChild: ReturnType | undefined; try { const result = await runTauriDev([], { resolveDevEndpoint: resolveTestEndpoint, @@ -245,9 +325,10 @@ describe('AI 游戏创作 Tauri dev 生命周期', () => { expect(result).toBe(42); await waitForFile(stoppedPath); } finally { - if (Number.isInteger(cliChild?.pid)) { + const cliPid = cliChild?.pid; + if (Number.isInteger(cliPid)) { try { - process.kill(-cliChild.pid, 'SIGKILL'); + process.kill(-cliPid!, 'SIGKILL'); } catch { // 进程组已经由启动器收束。 } @@ -283,7 +364,7 @@ describe('AI 游戏创作 Tauri dev 生命周期', () => { } }, 10); `; - let cliChild; + let cliChild: ReturnType | undefined; try { const result = await runTauriDev([], { resolveDevEndpoint: resolveTestEndpoint, @@ -304,11 +385,12 @@ describe('AI 游戏创作 Tauri dev 生命周期', () => { }); expect(result).toBe(42); - expect(isProcessGroupAlive(cliChild.pid)).toBe(false); + expect(isProcessGroupAlive(cliChild!.pid)).toBe(false); } finally { - if (Number.isInteger(cliChild?.pid)) { + const cliPid = cliChild?.pid; + if (Number.isInteger(cliPid)) { try { - process.kill(-cliChild.pid, 'SIGKILL'); + process.kill(-cliPid!, 'SIGKILL'); } catch { // 进程组已经由启动器强制收束。 } @@ -417,7 +499,7 @@ describe('AI 游戏创作 Tauri dev 随包资源准备目标', () => { 'x86_64-pc-windows-msvc', hostTarget, ]); - expect(calls[0][1]).toEqual(windowsFeatures); + expect(calls[0]![1]).toEqual(windowsFeatures); }); test('宿主平台不受声明覆盖时跳过准备,且不为其新增随包支持', () => { @@ -471,12 +553,12 @@ describe('AI 游戏创作 Tauri dev 随包资源准备目标', () => { expect(prepared.map(([features]) => features)).toEqual([ ['custom-feature'], ]); - expect(spawned[0].slice(0, 3)).toEqual(['dev', '-f', 'custom-feature']); - expect(spawned[0]).not.toContain( + expect(spawned[0]!.slice(0, 3)).toEqual(['dev', '-f', 'custom-feature']); + expect(spawned[0]!).not.toContain( `--features=${windowsFeatures.join(',')}`, ); // 随包资源准备拿到的是最终交给 Tauri 的参数,不是启动器收到的原始参数。 - expect(prepared[0][1]).toEqual(spawned[0]); + expect(prepared[0]![1]).toEqual(spawned[0]!); } finally { vi.unstubAllEnvs(); } @@ -492,14 +574,14 @@ describe('AI 游戏创作 Tauri dev 随包资源准备目标', () => { expect(result).toBe(0); expect(prepared.map(([features]) => features)).toEqual([[]]); - expect(prepared[0][1]).toEqual([ + expect(prepared[0]![1]).toEqual([ 'dev', '--target', 'x86_64-apple-darwin', '--config', devConfig, ]); - expect(spawned[0]).toEqual(prepared[0][1]); + expect(spawned[0]!).toEqual(prepared[0]![1]); } finally { vi.unstubAllEnvs(); } @@ -515,7 +597,7 @@ describe('AI 游戏创作 Tauri dev 随包资源准备目标', () => { expect(result).toBe(0); expect(prepared.map(([features]) => features)).toEqual([[]]); - expect(spawned[0]).toEqual([ + expect(spawned[0]!).toEqual([ 'dev', '--config', devConfig, @@ -523,7 +605,7 @@ describe('AI 游戏创作 Tauri dev 随包资源准备目标', () => { '--', '--features=app-tool', ]); - expect(prepared[0][1]).toEqual(spawned[0]); + expect(prepared[0]![1]).toEqual(spawned[0]!); } finally { vi.unstubAllEnvs(); } @@ -537,7 +619,7 @@ describe('AI 游戏创作 Tauri dev 随包资源准备目标', () => { expect(result).toBe(0); expect(prepared.map(([features]) => features)).toEqual([[]]); expect( - spawned[0].some((argument) => argument.startsWith('--features')), + spawned[0]!.some((argument) => argument.startsWith('--features')), ).toBe(false); } finally { vi.unstubAllEnvs(); diff --git a/apps/ai-game-creator-shell/tests/tauriEventFake.ts b/apps/ai-game-creator-shell/tests/tauriEventFake.ts index bcc8b4d7d..008145180 100644 --- a/apps/ai-game-creator-shell/tests/tauriEventFake.ts +++ b/apps/ai-game-creator-shell/tests/tauriEventFake.ts @@ -102,8 +102,8 @@ export function createTauriEventFake( flushRegistrationEvals() { const pending = registrations.splice(0, registrations.length); for (const listener of pending) { - registry[listener.event] ??= {}; - registry[listener.event][listener.eventId] = { + const eventRegistry = (registry[listener.event] ??= {}); + eventRegistry[listener.eventId] = { handlerId: listener.handlerId, }; } diff --git a/apps/ai-game-creator-shell/tests/templateLibraryView.test.tsx b/apps/ai-game-creator-shell/tests/templateLibraryView.test.tsx index 5e3d3086d..83583e196 100644 --- a/apps/ai-game-creator-shell/tests/templateLibraryView.test.tsx +++ b/apps/ai-game-creator-shell/tests/templateLibraryView.test.tsx @@ -2,9 +2,11 @@ import { fireEvent, render, screen, within } from '@testing-library/react'; import { beforeAll, describe, expect, it, vi } from 'vitest'; +import type { InitLocalProjectResult } from '../src/app/types'; import { computeTemplateGridLayout } from '../src/features/template-library/templateLibraryGrid'; import type { GameTemplateEntry, + InstalledGameTemplate, TemplateLibraryFilters, } from '../src/features/template-library/templateLibraryModel'; import { @@ -285,8 +287,16 @@ describe('TemplateLibraryView', () => { }); it('starts a download and a template project from the card actions', () => { - const downloadTemplate = vi.fn(async () => undefined); - const createProjectFromTemplate = vi.fn(async () => undefined); + // 这两个 mock 只用于断言「被调用」,视图会 `.catch` 掉结果并丢弃; + // 运行时仍返回已 resolve 的 undefined,类型层用 `as unknown as` 补齐合同返回值。 + const downloadTemplate = vi.fn< + [template: GameTemplateEntry], + Promise + >(async () => undefined as unknown as InstalledGameTemplate); + const createProjectFromTemplate = vi.fn< + [template: GameTemplateEntry], + Promise + >(async () => undefined as unknown as InitLocalProjectResult); render( { it('rejects an empty image list without invoking the command', async () => { @@ -25,10 +28,15 @@ describe('ui design resource bridge', () => { committedProjectRevision: 7, }; const invoke = vi.fn().mockResolvedValue(created); + // 真实类型用「省略 path」表达没有路径,而本用例的调用断言固定了 `path: null`; + // 结构上无法用该联合类型表达,只能断言收敛,运行时载荷原样保留。 + const images = [ + { assetId: 'image-a', path: null }, + ] as unknown as UiDesignDocImageReference[]; const result = await createUiDesignDocFromImages({ projectPath: '/project', expectedProjectId: 'project-1', - images: [{ assetId: 'image-a', path: null }], + images, invoke, }); expect(invoke).toHaveBeenCalledWith('create_ui_design_doc_from_images', { diff --git a/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts b/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts index 6575d7c54..62a4c6243 100644 --- a/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts +++ b/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts @@ -37,6 +37,7 @@ import type { IUiDesignStateStore, UiDesignStateSnapshot, } from '../src/features/ui-editor/uiDesignStateStore'; +import type { UiEditorOperationResult } from '../src/features/ui-editor/useUiEditorState'; import UiEditorPage from '../src/view/ui-editor'; import { useUiEditorSession } from '../src/view/ui-editor/useUiEditorPage'; @@ -92,11 +93,24 @@ function node(id: string, children: UiNode[] = []): UiNode { source: 'System', }, component: null, - children_display_mode: undefined, + children_display_mode: 'Stack', children, + // 真实 Node 现在要求 `offset`,但本 fixture 是「编辑器载入前」的历史形态: + // 运行期必须保持缺省,PreviewWorkspace 才会按旧口径跳过布局;这里只做类型收敛。 + offset: undefined as unknown as UiNode['offset'], }; } +/** + * `insertNode` 返回判别联合 `{ ok: true; value } | { ok: false; reason }`,失败分支没有 + * `value`。测试只取成功分支的节点 id,与原来的 `?.value` 在运行期等价。 + */ +function insertedNodeId( + result: UiEditorOperationResult | undefined, +): string | undefined { + return result?.ok ? result.value : undefined; +} + function stateWithPages(pageIds: string[]): State { return { ui_design_images: Object.fromEntries( @@ -224,11 +238,13 @@ describe('UiEditorPage', () => { expect.objectContaining({ scopeId: expect.any(String) }), ), ); - const firstScopeId = vi - .mocked(invoke) - .mock.calls.find( - ([command]) => command === 'read_local_project_image_preview', - )?.[1]?.scopeId; + const firstScopeId = ( + vi + .mocked(invoke) + .mock.calls.find( + ([command]) => command === 'read_local_project_image_preview', + )?.[1] as Record | undefined + )?.scopeId; hook.rerender({ resourceId: 'ui-resource-b' }); await waitFor(() => @@ -246,12 +262,14 @@ describe('UiEditorPage', () => { ), ).toHaveLength(4), ); - const secondScopeId = vi - .mocked(invoke) - .mock.calls.filter( - ([command]) => command === 'read_local_project_image_preview', - ) - .at(-1)?.[1]?.scopeId; + const secondScopeId = ( + vi + .mocked(invoke) + .mock.calls.filter( + ([command]) => command === 'read_local_project_image_preview', + ) + .at(-1)?.[1] as Record | undefined + )?.scopeId; hook.unmount(); @@ -461,10 +479,8 @@ describe('UiEditorPage', () => { expect(play).toHaveBeenCalledTimes(4); expect(scrollSpy).toHaveBeenCalledTimes(4); } finally { - delete (Element.prototype as Element & { getAnimations?: unknown }) - .getAnimations; - delete (Element.prototype as Element & { scrollIntoView?: unknown }) - .scrollIntoView; + delete (Element.prototype as { getAnimations?: unknown }).getAnimations; + delete (Element.prototype as { scrollIntoView?: unknown }).scrollIntoView; } }); @@ -511,8 +527,7 @@ describe('UiEditorPage', () => { }); } } finally { - delete (Element.prototype as Element & { scrollIntoView?: unknown }) - .scrollIntoView; + delete (Element.prototype as { scrollIntoView?: unknown }).scrollIntoView; } }); @@ -626,8 +641,12 @@ describe('UiEditorPage', () => { let parentId: string | undefined; let childId: string | undefined; act(() => { - parentId = result.current.input.insertNode(rootId, 'page')?.value; - childId = result.current.input.insertNode(parentId!, 'page')?.value; + parentId = insertedNodeId( + result.current.input.insertNode(rootId, 'page'), + ); + childId = insertedNodeId( + result.current.input.insertNode(parentId!, 'page'), + ); result.current.input.selectNode(childId!); }); @@ -706,7 +725,9 @@ describe('UiEditorPage', () => { const rootId = 'page-root'; let otherNodeId: string | undefined; act(() => { - otherNodeId = result.current.input.insertNode(rootId, 'page')?.value; + otherNodeId = insertedNodeId( + result.current.input.insertNode(rootId, 'page'), + ); result.current.input.selectNode(rootId); result.current.input.highlightStatusField(rootId, 'layout_status'); }); @@ -745,7 +766,9 @@ describe('UiEditorPage', () => { const rootId = 'page-root'; let otherNodeId: string | undefined; act(() => { - otherNodeId = result.current.input.insertNode(rootId, 'page')?.value; + otherNodeId = insertedNodeId( + result.current.input.insertNode(rootId, 'page'), + ); result.current.input.focusNode('page', rootId); result.current.input.highlightStatusField(rootId, 'layout_status'); }); @@ -774,13 +797,18 @@ describe('UiEditorPage', () => { let childBId: string | undefined; let childBDescendantId: string | undefined; act(() => { - parentId = result.current.input.insertNode(rootId, 'page')?.value; - childAId = result.current.input.insertNode(parentId!, 'page')?.value; - childBId = result.current.input.insertNode(parentId!, 'page')?.value; - childBDescendantId = result.current.input.insertNode( - childBId!, - 'page', - )?.value; + parentId = insertedNodeId( + result.current.input.insertNode(rootId, 'page'), + ); + childAId = insertedNodeId( + result.current.input.insertNode(parentId!, 'page'), + ); + childBId = insertedNodeId( + result.current.input.insertNode(parentId!, 'page'), + ); + childBDescendantId = insertedNodeId( + result.current.input.insertNode(childBId!, 'page'), + ); }); act(() => result.current.input.selectNode(parentId!)); act(() => result.current.inspector.setNodeChildrenDisplayMode('Exclusive')); @@ -1068,7 +1096,7 @@ describe('UiEditorPage', () => { ); expect(calls).toHaveLength(count); if (count) - expect(calls[0][1]).toMatchObject({ + expect(calls[0]?.[1]).toMatchObject({ projectPath: '/tmp/ui-editor', saveSource, changed: status === 'saved', diff --git a/apps/ai-game-creator-shell/tests/uiEditorPreview.test.tsx b/apps/ai-game-creator-shell/tests/uiEditorPreview.test.tsx index 585e308ba..1fbbd8067 100644 --- a/apps/ai-game-creator-shell/tests/uiEditorPreview.test.tsx +++ b/apps/ai-game-creator-shell/tests/uiEditorPreview.test.tsx @@ -41,6 +41,7 @@ function node(id: string, children: UiNode[] = []): UiNode { component: null, children_display_mode: 'Stack', children, + offset: { min: [0, 0], max: [0, 0] }, }; } @@ -61,20 +62,21 @@ const resources = { }; function renderTree( - renderMode: 'editor-overlay' | 'final-preview', hiddenNodeIds: ReadonlySet, options: { showFrame?: boolean; + showComponent?: boolean; selectedNodeId?: string | null; onSelectNode?: ReturnType; onNodeContextMenu?: ReturnType; + onSelectExclusiveChild?: (nodeId: string) => void; } = {}, ) { return render( , ); @@ -95,7 +98,7 @@ function renderTree( describe('UI tree preview visibility', () => { it('disables native text selection on preview node layers', () => { - const rendered = renderTree('editor-overlay', new Set()); + const rendered = renderTree(new Set()); expect( rendered.container @@ -106,7 +109,7 @@ describe('UI tree preview visibility', () => { it('does not use click alone to select directly draggable nodes', () => { const onSelectNode = vi.fn(); - const rendered = renderTree('editor-overlay', new Set(), { onSelectNode }); + const rendered = renderTree(new Set(), { onSelectNode }); fireEvent.click( rendered.container.querySelector('[data-node-id="child"]')!, ); @@ -187,8 +190,8 @@ describe('UI tree preview visibility', () => { const rendered = render( { onNodeResizePointerMove={vi.fn()} onNodeResizePointerUp={vi.fn()} onNodeResizePointerCancel={vi.fn()} + onSelectExclusiveChild={vi.fn()} viewportScale={1} />, ); @@ -219,7 +223,7 @@ describe('UI tree preview visibility', () => { }); it('shows final-preview node frames and resize handles only when requested', () => { - const withoutFrames = renderTree('final-preview', new Set()); + const withoutFrames = renderTree(new Set()); const plainChild = withoutFrames.container.querySelector( '[data-node-id="child"]', ) as HTMLDivElement; @@ -227,7 +231,7 @@ describe('UI tree preview visibility', () => { expect(screen.queryByLabelText('nw 调整控制点')).toBeNull(); withoutFrames.unmount(); - const withFrames = renderTree('final-preview', new Set(), { + const withFrames = renderTree(new Set(), { showFrame: true, selectedNodeId: 'child', }); @@ -240,7 +244,7 @@ describe('UI tree preview visibility', () => { // root 是整树容器:它自己即使被选中也不能出现 resize 控制点,子节点仍按上面那 8 个。 withFrames.unmount(); - const withRootSelected = renderTree('final-preview', new Set(), { + const withRootSelected = renderTree(new Set(), { showFrame: true, selectedNodeId: 'root', }); @@ -251,7 +255,7 @@ describe('UI tree preview visibility', () => { }); it('hides a node subtree in editor overlay and final preview', () => { - const editorOverlay = renderTree('editor-overlay', new Set(['child'])); + const editorOverlay = renderTree(new Set(['child'])); expect( editorOverlay.container.querySelector('[data-node-id="root"]'), ).toBeTruthy(); @@ -262,7 +266,7 @@ describe('UI tree preview visibility', () => { editorOverlay.container.querySelector('[data-node-id="grandchild"]'), ).toBeNull(); - const finalPreview = renderTree('final-preview', new Set(['root'])); + const finalPreview = renderTree(new Set(['root'])); expect(finalPreview.container.querySelector('[data-node-id]')).toBeNull(); }); @@ -275,8 +279,8 @@ describe('UI tree preview visibility', () => { render( { onNodeResizePointerMove={vi.fn()} onNodeResizePointerUp={vi.fn()} onNodeResizePointerCancel={vi.fn()} + onSelectExclusiveChild={vi.fn()} viewportScale={1} />, ); @@ -349,8 +354,8 @@ describe('UI tree preview visibility', () => { render( { const editorOverlay = render( { onNodeResizePointerMove={vi.fn()} onNodeResizePointerUp={vi.fn()} onNodeResizePointerCancel={vi.fn()} + onSelectExclusiveChild={vi.fn()} viewportScale={1} />, ); @@ -414,7 +420,7 @@ describe('UI tree preview visibility', () => { ); editorOverlay.unmount(); - const finalPreview = renderTree('final-preview', new Set(), { + const finalPreview = renderTree(new Set(), { onNodeContextMenu, }); fireEvent.contextMenu( diff --git a/apps/ai-game-creator-shell/tests/uiEditorState.test.ts b/apps/ai-game-creator-shell/tests/uiEditorState.test.ts index e7e5d1c85..d4b5ec699 100644 --- a/apps/ai-game-creator-shell/tests/uiEditorState.test.ts +++ b/apps/ai-game-creator-shell/tests/uiEditorState.test.ts @@ -84,7 +84,9 @@ function nodeWithSprite(id: string): Node { image_type: { Simple: { preserve_aspect: false } }, }, }, + children_display_mode: 'Stack', children: [], + offset: { min: [0, 0], max: [0, 0] }, }; } @@ -116,6 +118,7 @@ function pageRoot(id: string, children: Node[] = []): Node { component: null, children_display_mode: 'Stack', children, + offset: { min: [0, 0], max: [0, 0] }, }; } @@ -465,6 +468,7 @@ describe('useUiEditorState', () => { component: null, children_display_mode: 'Stack', children: [nodeWithSprite('panel')], + offset: { min: [0, 0], max: [0, 0] }, }, }, ], @@ -566,13 +570,18 @@ describe('useUiEditorState', () => { }; const { result } = renderHook(() => useUiEditorState(initial)); + // 夹具根节点就是文本组件;先收窄联合类型,后面才能展开出完整 TextComponent。 + const rootComponent = initial.ui_trees[0]!.root.component; + if (!rootComponent || !('Text' in rootComponent)) { + throw new Error('夹具根节点必须是文本组件'); + } + const existingText = rootComponent.Text; + act(() => { expect( result.current.setNodeComponent('page', 'text-root', { Text: { - ...('Text' in initial.ui_trees[0]!.root.component! - ? initial.ui_trees[0]!.root.component!.Text - : {}), + ...existingText, font: { Bound: 'body' }, }, }), diff --git a/apps/ai-game-creator-shell/tests/uiTreeNodeCycle.test.ts b/apps/ai-game-creator-shell/tests/uiTreeNodeCycle.test.ts index fe73af837..18e53eea4 100644 --- a/apps/ai-game-creator-shell/tests/uiTreeNodeCycle.test.ts +++ b/apps/ai-game-creator-shell/tests/uiTreeNodeCycle.test.ts @@ -40,7 +40,7 @@ function node( source: 'System', }, component: null, - children_display_mode: undefined, + children_display_mode: 'Stack', offset: { min: [0, 0], max: [0, 0] }, children: [], }; diff --git a/apps/ai-game-creator-shell/tests/uiTreeUtils.test.ts b/apps/ai-game-creator-shell/tests/uiTreeUtils.test.ts index 2795e71f1..cae393966 100644 --- a/apps/ai-game-creator-shell/tests/uiTreeUtils.test.ts +++ b/apps/ai-game-creator-shell/tests/uiTreeUtils.test.ts @@ -14,6 +14,7 @@ function node(id: string, children: Node[] = []): Node { component: null, children_display_mode: 'Stack', children, + offset: {} as Node['offset'], }; } diff --git a/apps/ai-game-creator-shell/tests/unhandledRejectionBridge.test.ts b/apps/ai-game-creator-shell/tests/unhandledRejectionBridge.test.ts index 94287a7cb..b7812e2cd 100644 --- a/apps/ai-game-creator-shell/tests/unhandledRejectionBridge.test.ts +++ b/apps/ai-game-creator-shell/tests/unhandledRejectionBridge.test.ts @@ -12,7 +12,7 @@ import { installUnhandledRejectionBridge } from './unhandledRejectionBridge'; const installed: Array<() => void> = []; const processListeners: Array<(reason: unknown, promise: unknown) => void> = []; const windowListeners: Array<(event: Event) => void> = []; -let parkedListeners: Array<(reason: unknown, promise: unknown) => void> = []; +let parkedListeners: NodeJS.UnhandledRejectionListener[] = []; afterEach(() => { while (installed.length > 0) installed.pop()?.(); diff --git a/apps/ai-game-creator-shell/tests/unityProjectOpen.test.tsx b/apps/ai-game-creator-shell/tests/unityProjectOpen.test.tsx index 82b5d950c..3e4abf7c1 100644 --- a/apps/ai-game-creator-shell/tests/unityProjectOpen.test.tsx +++ b/apps/ai-game-creator-shell/tests/unityProjectOpen.test.tsx @@ -10,6 +10,17 @@ afterEach(() => { delete window.__TAURI__; }); +type TauriInvoke = NonNullable< + NonNullable['core']>['invoke'] +>; + +function asTauriInvoke( + invoke: (command: string, args?: Record) => Promise, +): TauriInvoke { + // 泛型 invoke 与具体的非泛型 mock 结构上不可兼容,只能用 as unknown as 收敛。 + return invoke as unknown as TauriInvoke; +} + test.each([false, true])( 'Unity 项目通过现有工作台打开,已有元数据=%s', async (initialized) => { @@ -42,13 +53,13 @@ test.each([false, true])( return { activeRuntime: 'game' }; throw new Error(`意外调用:${command}`); }); - window.__TAURI__ = { core: { invoke } }; + window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke) } }; const setLauncherView = vi.fn(); const { result } = renderHook(() => useHomeProjectCreation({ setStatus: vi.fn(), + showTransientNotice: vi.fn(), setLauncherView, - setAgentChatProjectPath: vi.fn(), rememberRecentWorkspace: vi.fn(), }), ); diff --git a/apps/ai-game-creator-shell/tests/useNodeTransformInteraction.test.tsx b/apps/ai-game-creator-shell/tests/useNodeTransformInteraction.test.tsx index f135a1db6..77fa628e0 100644 --- a/apps/ai-game-creator-shell/tests/useNodeTransformInteraction.test.tsx +++ b/apps/ai-game-creator-shell/tests/useNodeTransformInteraction.test.tsx @@ -1,6 +1,7 @@ // @vitest-environment jsdom import { act, renderHook } from '@testing-library/react'; +import type { PointerEvent as ReactPointerEvent } from 'react'; import { describe, expect, it, vi } from 'vitest'; import type { Node as UiNode } from '../src/features/ui-editor/types/Node'; @@ -34,7 +35,9 @@ function node(id: string, children: UiNode[] = []): UiNode { source: 'System', }, component: null, + children_display_mode: 'Stack', children, + offset: { min: [0, 0], max: [0, 0] }, }; } @@ -82,7 +85,8 @@ function pointerEvent( preventDefault: vi.fn(), shiftKey: false, stopPropagation: vi.fn(), - } as never; + // 测试只造出 hook 用到的少数字段,与完整的 React 合成事件结构上不可兼容,故用 as unknown as 收敛。 + } as unknown as ReactPointerEvent; } function canvas() { diff --git a/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts b/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts index 4f8f84402..c1b579d58 100644 --- a/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts +++ b/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts @@ -78,6 +78,19 @@ function automaticPosition( }; } +/** + * 测试装配用的 Tauri invoke 桩入口。 + * + * `TauriInvoke` 是泛型函数签名,而 `vi.fn` 返回的是带具体返回类型的 Mock, + * 具体签名无法赋给泛型签名;这里集中做一次桥接,避免每个用例重复断言。 + */ +function installTauriInvoke( + invoke: (command: string, args?: Record) => Promise, +): void { + // 泛型签名与具体 Mock 结构上不可兼容,这里显式转换以完成测试装配。 + window.__TAURI__ = { core: { invoke } } as unknown as typeof window.__TAURI__; +} + afterEach(() => { cleanup(); window.__TAURI__ = undefined; @@ -151,7 +164,7 @@ describe('useProjectResourceCanvasLayout', () => { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriInvoke(invoke); const shallowResource = resource('resource-a'); const deepResource = { ...shallowResource, dependencyDepth: 2 }; const { result, rerender } = renderHook( @@ -189,7 +202,7 @@ describe('useProjectResourceCanvasLayout', () => { it('can render an unpersisted fallback after graph failure without becoming settled', () => { const invoke = vi.fn(); - window.__TAURI__ = { core: { invoke } }; + installTauriInvoke(invoke); const { result } = renderHook(() => useProjectResourceCanvasLayout({ @@ -234,7 +247,7 @@ describe('useProjectResourceCanvasLayout', () => { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriInvoke(invoke); renderHook(() => useProjectResourceCanvasLayout({ @@ -300,7 +313,7 @@ describe('useProjectResourceCanvasLayout', () => { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriInvoke(invoke); const { result } = renderHook(() => useProjectResourceCanvasLayout({ @@ -363,7 +376,7 @@ describe('useProjectResourceCanvasLayout', () => { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriInvoke(invoke); const { rerender } = renderHook( ({ topology }) => useProjectResourceCanvasLayout({ @@ -416,7 +429,7 @@ describe('useProjectResourceCanvasLayout', () => { } throw new Error(`unexpected invoke ${command}`); }); - window.__TAURI__ = { core: { invoke } }; + installTauriInvoke(invoke); const { result } = renderHook(() => useProjectResourceCanvasLayout({ @@ -462,7 +475,7 @@ describe('useProjectResourceCanvasLayout', () => { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriInvoke(invoke); const { result } = renderHook(() => useProjectResourceCanvasLayout({ projectPath, @@ -518,7 +531,7 @@ describe('useProjectResourceCanvasLayout', () => { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriInvoke(invoke); const { result, rerender } = renderHook( ({ resources }: { resources: ResourceCanvasItem[] }) => useProjectResourceCanvasLayout({ @@ -600,7 +613,7 @@ describe('useProjectResourceCanvasLayout', () => { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriInvoke(invoke); const { result, rerender } = renderHook( ({ resources }: { resources: ResourceCanvasItem[] }) => useProjectResourceCanvasLayout({ @@ -696,7 +709,7 @@ describe('useProjectResourceCanvasLayout', () => { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriInvoke(invoke); const { result } = renderHook(() => useProjectResourceCanvasLayout({ projectPath, @@ -760,7 +773,7 @@ describe('useProjectResourceCanvasLayout', () => { } throw new Error(`unexpected invoke ${command}`); }); - window.__TAURI__ = { core: { invoke } }; + installTauriInvoke(invoke); const { result } = renderHook(() => useProjectResourceCanvasLayout({ projectPath, @@ -840,7 +853,7 @@ describe('useProjectResourceCanvasLayout', () => { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriInvoke(invoke); const setTimeoutSpy = vi.spyOn(window, 'setTimeout'); const { result } = renderHook(() => useProjectResourceCanvasLayout({ @@ -927,7 +940,7 @@ describe('useProjectResourceCanvasLayout', () => { }); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriInvoke(invoke); const { result, rerender } = renderHook( ({ mode }: { mode: ProjectResourceCanvasLayoutMode }) => useProjectResourceCanvasLayout({ @@ -936,7 +949,9 @@ describe('useProjectResourceCanvasLayout', () => { mode, resources: [resourceA], }), - { initialProps: { mode: 'dependency' as const } }, + { + initialProps: { mode: 'dependency' as ProjectResourceCanvasLayoutMode }, + }, ); await waitFor(() => expect(readResolvers.has('dependency')).toBe(true)); rerender({ mode: 'type' }); @@ -998,7 +1013,7 @@ describe('useProjectResourceCanvasLayout', () => { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriInvoke(invoke); const { result, rerender } = renderHook( ({ mode }: { mode: ProjectResourceCanvasLayoutMode }) => useProjectResourceCanvasLayout({ @@ -1007,7 +1022,9 @@ describe('useProjectResourceCanvasLayout', () => { mode, resources: [resourceA], }), - { initialProps: { mode: 'dependency' as const } }, + { + initialProps: { mode: 'dependency' as ProjectResourceCanvasLayoutMode }, + }, ); await waitFor(() => expect(result.current.layout.positions[0]?.x).toBe(10)); @@ -1061,7 +1078,7 @@ describe('useProjectResourceCanvasLayout', () => { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriInvoke(invoke); const { result } = renderHook(() => useProjectResourceCanvasLayout({ projectPath, @@ -1120,7 +1137,7 @@ describe('useProjectResourceCanvasLayout', () => { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriInvoke(invoke); const { result } = renderHook(() => useProjectResourceCanvasLayout({ projectPath, @@ -1225,7 +1242,7 @@ describe('useProjectResourceCanvasLayout', () => { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriInvoke(invoke); const { result } = renderHook(() => useProjectResourceCanvasLayout({ projectPath, @@ -1290,7 +1307,7 @@ describe('useProjectResourceCanvasLayout', () => { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriInvoke(invoke); const { result } = renderHook(() => useProjectResourceCanvasLayout({ projectPath, @@ -1326,7 +1343,7 @@ describe('useProjectResourceCanvasLayout', () => { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriInvoke(invoke); const { result } = renderHook(() => useProjectResourceCanvasLayout({ projectPath, @@ -1397,7 +1414,14 @@ describe('resource canvas layout read report', () => { const counts = inspectProjectResourceCanvasLayoutRead( layoutWith([ { resourceId: 'a', section: 'code', x: 1, y: 2, manuallyPlaced: true }, - { resourceId: 'b', section: 'nope', x: 3, y: 4, manuallyPlaced: true }, + { + resourceId: 'b', + // 用例刻意喂入无法识别的分区取值,类型层收口到真实联合但不改运行时串。 + section: 'nope' as ProjectResourceCanvasPosition['section'], + x: 3, + y: 4, + manuallyPlaced: true, + }, ]), [ { ...resource('a'), category: 'document' }, @@ -1480,7 +1504,7 @@ describe('布局落盘 / 读盘失败的原因不再被吞掉', () => { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriInvoke(invoke); const { result } = renderHook(() => useProjectResourceCanvasLayout({ @@ -1520,7 +1544,7 @@ describe('布局落盘 / 读盘失败的原因不再被吞掉', () => { } throw new Error(`unexpected invoke ${command}`); }); - window.__TAURI__ = { core: { invoke } }; + installTauriInvoke(invoke); const { result } = renderHook(() => useProjectResourceCanvasLayout({ @@ -1581,7 +1605,7 @@ describe('资源画布整理与批量坐标写入', () => { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriInvoke(invoke); return { invoke, updates }; } @@ -1631,7 +1655,7 @@ describe('资源画布整理与批量坐标写入', () => { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriInvoke(invoke); return { invoke, updates, @@ -2176,7 +2200,7 @@ describe('资源画布整理与批量坐标写入', () => { throw new Error(`unexpected invoke ${command}`); }, ); - window.__TAURI__ = { core: { invoke } }; + installTauriInvoke(invoke); const { result, rerender } = renderHook( ({ mode }: { mode: ProjectResourceCanvasLayoutMode }) => useProjectResourceCanvasLayout({ @@ -2185,7 +2209,7 @@ describe('资源画布整理与批量坐标写入', () => { mode, resources: [documentA, documentB], }), - { initialProps: { mode: 'type' as const } }, + { initialProps: { mode: 'type' as ProjectResourceCanvasLayoutMode } }, ); await waitFor(() => expect(result.current.settled).toBe(true)); typeWrites.length = 0; @@ -2524,7 +2548,7 @@ describe('资源画布整理与批量坐标写入', () => { } throw new Error(`unexpected invoke ${command}`); }); - window.__TAURI__ = { core: { invoke } }; + installTauriInvoke(invoke); const { result } = renderHook(() => useProjectResourceCanvasLayout({ diff --git a/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts b/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts index 6db7c7e3a..e9d192e1e 100644 --- a/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts +++ b/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts @@ -8,8 +8,17 @@ import { waitFor, } from '@testing-library/react'; import React from 'react'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + afterEach, + beforeEach, + describe, + expect, + it, + type Mock, + vi, +} from 'vitest'; +import type { TauriInvoke } from '../src/app/types'; import { isProjectResourcePreviewCancellation } from '../src/services/projectResourcePreviewTransport'; import { createResourceCanvasPrefetchScopeKey } from '../src/view/project-development/resourceCanvasController'; import { @@ -28,6 +37,17 @@ type PreviewHookInput = Parameters< typeof useProjectResourceCardPreviewsUnderTest >[0]; +/** + * `vi.fn` 的返回类型是固定签名,装不进 `window.__TAURI__.core.invoke` 的泛型签名; + * 这里集中做一次显式转换:`as unknown as` 只走类型层,运行时原样返回同一个 Mock, + * 用例仍能继续用 `.mock` 断言。 + */ +function asTauriInvoke( + invoke: Mock, +): TauriInvoke { + return invoke as unknown as TauriInvoke; +} + /** * 生产里 `prefetchScopeKey` 是**必填**的视图标识,由 `index.tsx` 用 * `createResourceCanvasPrefetchScopeKey` 按「视图 + 栏目 + 排序模式」算出。 @@ -158,7 +178,7 @@ describe('useProjectResourceCardPreviews', () => { : {}), }), ); - window.__TAURI__ = { core: { invoke } }; + window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke) } }; const resources = [json]; const canvasRef = { current: document.createElement('div') }; const { result, rerender } = renderHook( @@ -260,7 +280,7 @@ describe('useProjectResourceCardPreviews', () => { async (_command: string, args?: Record) => preview(String(args?.relativePath ?? art.path)), ); - window.__TAURI__ = { core: { invoke } }; + window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke) } }; const canvasRef = { current: document.createElement('div') }; const { result } = renderHook(() => @@ -294,7 +314,7 @@ describe('useProjectResourceCardPreviews', () => { mediaType: 'image/gif', }); const invoke = vi.fn(async () => preview(art.path, 'image/gif')); - window.__TAURI__ = { core: { invoke } }; + window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke) } }; const canvasRef = { current: document.createElement('div') }; const { result } = renderHook(() => @@ -335,8 +355,12 @@ describe('useProjectResourceCardPreviews', () => { // 6 类资产分类轴落地后的画布栏目取值,与线上读取分支是两个轴。 category: 'unclassified', }); - const invoke = vi.fn(async () => preview(art.path, 'image/gif')); - window.__TAURI__ = { core: { invoke } }; + // 补上 IPC 参数签名:下面要按「命令 + 入参」读回调用,空元组会取不到 index 1。 + const invoke = vi.fn( + async (_command: string, _args?: Record) => + preview(art.path, 'image/gif'), + ); + window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke) } }; const canvasRef = { current: document.createElement('div') }; const { result } = renderHook(() => @@ -371,7 +395,7 @@ describe('useProjectResourceCardPreviews', () => { category: 'audio', }); const invoke = vi.fn(async () => preview(audio.path, 'audio/mpeg')); - window.__TAURI__ = { core: { invoke } }; + window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke) } }; const canvasRef = { current: document.createElement('div') }; const { result } = renderHook(() => @@ -479,7 +503,7 @@ describe('useProjectResourceCardPreviews', () => { new Error('读取项目媒体资源失败:temporarily busy'), ) .mockResolvedValueOnce(preview(art.path)); - window.__TAURI__ = { core: { invoke } }; + window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke) } }; const canvasRef = { current: document.createElement('div') }; const { result } = renderHook(() => useProjectResourceCardPreviews({ @@ -520,7 +544,7 @@ describe('useProjectResourceCardPreviews', () => { .mockRejectedValue( new Error('image.inspect 图片结构无效:assets/broken.png'), ); - window.__TAURI__ = { core: { invoke } }; + window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke) } }; const canvasRef = { current: document.createElement('div') }; const { result } = renderHook(() => useProjectResourceCardPreviews({ @@ -564,11 +588,18 @@ describe('useProjectResourceCardPreviews', () => { const secondRead = deferred>(); const thirdRead = deferred>(); const blockedReads = [firstRead, secondRead, thirdRead]; - const invoke = vi.fn((_: string, args?: Record) => { + // 显式标注类型:mock 内部要读 `invoke.mock.calls`,有注解后不再形成自引用推断环。 + const invoke: Mock< + [command: string, args?: Record], + Promise> + > = vi.fn((_: string, args?: Record) => { const blocked = blockedReads[invoke.mock.calls.length - 1]; - return blocked?.promise ?? new Promise(() => undefined); + return ( + blocked?.promise ?? + new Promise>(() => undefined) + ); }); - window.__TAURI__ = { core: { invoke } }; + window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke) } }; const canvasRef = { current: document.createElement('div') }; const { result } = renderHook(() => useProjectResourceCardPreviews({ @@ -636,7 +667,7 @@ describe('useProjectResourceCardPreviews', () => { const invoke = vi.fn(async (_: string, args?: Record) => preview(String(args?.relativePath ?? ''), 'video/mp4'), ); - window.__TAURI__ = { core: { invoke } }; + window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke) } }; const canvasRef = { current: document.createElement('div') }; const { result, unmount } = renderHook(() => useProjectResourceCardPreviews({ @@ -676,7 +707,7 @@ describe('useProjectResourceCardPreviews', () => { const invoke = vi.fn(async (_: string, args?: Record) => preview(String(args?.relativePath ?? '')), ); - window.__TAURI__ = { core: { invoke } }; + window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke) } }; const canvasRef = { current: document.createElement('div') }; const { result, rerender, unmount } = renderHook( (resources: ProjectResource[]) => @@ -766,7 +797,7 @@ describe('useProjectResourceCardPreviews', () => { pending.push({ path: String(args?.relativePath ?? ''), read }); return read.promise; }); - window.__TAURI__ = { core: { invoke } }; + window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke) } }; const canvasRef = { current: document.createElement('div') }; const { result, rerender } = renderHook( (props: { @@ -858,13 +889,15 @@ describe('useProjectResourceCardPreviews', () => { it('preserves the preview identity, dimensions, and native scope for a mode-only change', async () => { const art = resource('mode-switch-art'); - const invoke = vi.fn(async (command: string) => { - if (command === 'cancel_local_project_resource_preview_scope') { - return undefined; - } - return preview(art.path); - }); - window.__TAURI__ = { core: { invoke } }; + const invoke = vi.fn( + async (command: string, _args?: Record) => { + if (command === 'cancel_local_project_resource_preview_scope') { + return undefined; + } + return preview(art.path); + }, + ); + window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke) } }; const canvasRef = { current: document.createElement('div') }; const { result, rerender, unmount } = renderHook( (mode: 'dependency' | 'type') => @@ -876,7 +909,8 @@ describe('useProjectResourceCardPreviews', () => { canvasRef, eagerPreviewLimit: 1, }), - { initialProps: 'dependency' as const }, + // 标注为 union:`as const` 会把 renderHook 的 Props 收窄成单个字面量,rerender('type') 报错。 + { initialProps: 'dependency' as 'dependency' | 'type' }, ); const firstIdentity = result.current.identityByResourceId.get(art.id)!; await waitFor(() => @@ -920,7 +954,7 @@ describe('useProjectResourceCardPreviews', () => { async (_command: string, args?: Record) => preview(String(args?.relativePath ?? art.path)), ); - window.__TAURI__ = { core: { invoke } }; + window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke) } }; const canvasRef = { current: document.createElement('div') }; const { result, rerender } = renderHook( (previewVersion: string) => @@ -967,7 +1001,7 @@ describe('useProjectResourceCardPreviews', () => { it('rejects an A-B-A late result even when scope and identity match again', async () => { const art = resource('aba-art'); const pending: DeferredPreview[] = []; - const invoke = vi.fn((command: string) => { + const invoke = vi.fn((command: string, _args?: Record) => { if (command === 'cancel_local_project_resource_preview_scope') { return Promise.resolve(); } @@ -975,7 +1009,7 @@ describe('useProjectResourceCardPreviews', () => { pending.push(read); return read.promise; }); - window.__TAURI__ = { core: { invoke } }; + window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke) } }; const canvasRef = { current: document.createElement('div') }; const { result, rerender } = renderHook( (props: { projectPath: string; projectId: string }) => @@ -1050,7 +1084,7 @@ describe('useProjectResourceCardPreviews', () => { async (_command: string, args?: Record) => preview(String(args?.relativePath ?? art.path)), ); - window.__TAURI__ = { core: { invoke } }; + window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke) } }; const canvasRef = { current: document.createElement('div') }; const intersectionRoot = document.createElement('div'); const callbacks: IntersectionObserverCallback[] = []; @@ -1124,7 +1158,11 @@ describe('useProjectResourceCardPreviews', () => { target: card, isIntersecting: true, intersectionRatio: 1, - } as IntersectionObserverEntry, + boundingClientRect: card.getBoundingClientRect(), + intersectionRect: card.getBoundingClientRect(), + rootBounds: null, + time: 0, + }, ], {} as IntersectionObserver, ); @@ -1241,7 +1279,7 @@ describe('useProjectResourceCardPreviews', () => { async (_command: string, args?: Record) => preview(String(args?.relativePath ?? art.path)), ); - window.__TAURI__ = { core: { invoke } }; + window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke) } }; // root 在首次渲染时还没有挂上(等价于画本容器尚未就绪)。 const canvasRef: { current: HTMLDivElement | null } = { current: null }; const harness = installIntersectionObserver(); @@ -1306,7 +1344,7 @@ describe('useProjectResourceCardPreviews', () => { async (_command: string, args?: Record) => preview(String(args?.relativePath ?? '')), ); - window.__TAURI__ = { core: { invoke } }; + window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke) } }; const canvasRef: { current: HTMLDivElement | null } = { current: null }; const harness = installIntersectionObserver(); @@ -1369,7 +1407,7 @@ describe('useProjectResourceCardPreviews', () => { async (_command: string, args?: Record) => preview(String(args?.relativePath ?? '')), ); - window.__TAURI__ = { core: { invoke } }; + window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke) } }; const canvasRef = { current: document.createElement('div') }; const callbacks: IntersectionObserverCallback[] = []; @@ -1513,7 +1551,7 @@ describe('useProjectResourceCardPreviews', () => { async (_command: string, args?: Record) => preview(String(args?.relativePath ?? art.path)), ); - window.__TAURI__ = { core: { invoke } }; + window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke) } }; const canvasRef: { current: HTMLDivElement | null } = { current: null }; const harness = installIntersectionObserver(); @@ -1556,7 +1594,7 @@ describe('useProjectResourceCardPreviews', () => { async (_command: string, args?: Record) => preview(String(args?.relativePath ?? art.path)), ); - window.__TAURI__ = { core: { invoke } }; + window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke) } }; const canvasRef = { current: document.createElement('div') }; const harness = installIntersectionObserver(); @@ -1664,7 +1702,7 @@ describe('useProjectResourceCardPreviews', () => { const held = deferred>(); const invoke = vi.fn(async () => held.promise); - window.__TAURI__ = { core: { invoke } }; + window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke) } }; const canvasRef = { current: document.createElement('div') }; const { result, rerender } = renderHook( @@ -1744,7 +1782,7 @@ describe('useProjectResourceCardPreviews', () => { }); const held = deferred>(); const invoke = vi.fn(async () => held.promise); - window.__TAURI__ = { core: { invoke } }; + window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke) } }; const canvasRef = { current: document.createElement('div') }; const { result, rerender } = renderHook( @@ -1822,7 +1860,7 @@ describe('预览队列上限吞掉按需请求时的可观测性', () => { ); // 所有读取都挂住:队列只进不出,才能在用例里稳定压满。 const invoke = vi.fn(() => new Promise(() => undefined)); - window.__TAURI__ = { core: { invoke } }; + window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke) } }; const canvasRef = { current: document.createElement('div') }; const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); const { result } = renderHook(() => @@ -2090,7 +2128,7 @@ describe('冷启动首屏的放行范围与放行顺序', () => { async (_command: string, args?: Record) => preview(String(args?.relativePath ?? '')), ); - window.__TAURI__ = { core: { invoke } }; + window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke) } }; render( React.createElement(PreviewBandHarness, { @@ -2130,7 +2168,7 @@ describe('冷启动首屏的放行范围与放行顺序', () => { // 登记顺序故意先余量圈、后视口内:单趟入队会把这个顺序原样写进队列。 const resources = [...marginOnly, ...inViewport]; const invoke = vi.fn(() => new Promise(() => undefined)); - window.__TAURI__ = { core: { invoke } }; + window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke) } }; const harness = installRecordingIntersectionObserver(); const controllerRef: { current: PreviewController | null } = { current: null, @@ -2203,7 +2241,7 @@ describe('预览缓存驱逐必须避开可见卡', () => { async (_command: string, args?: Record) => preview(String(args?.relativePath ?? '')), ); - window.__TAURI__ = { core: { invoke } }; + window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke) } }; const canvasRef = { current: document.createElement('div') }; const { result } = renderHook(() => @@ -2283,7 +2321,7 @@ describe('预览缓存驱逐必须避开可见卡', () => { const invoke = vi.fn(async (_: string, args?: Record) => preview(String(args?.relativePath ?? '')), ); - window.__TAURI__ = { core: { invoke } }; + window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke) } }; const canvasRef = { current: document.createElement('div') }; const { result } = renderHook(() => useProjectResourceCardPreviews({ diff --git a/apps/ai-game-creator-shell/tests/useTemplateLibrary.test.tsx b/apps/ai-game-creator-shell/tests/useTemplateLibrary.test.tsx index 9cbda5a08..f1a941123 100644 --- a/apps/ai-game-creator-shell/tests/useTemplateLibrary.test.tsx +++ b/apps/ai-game-creator-shell/tests/useTemplateLibrary.test.tsx @@ -272,7 +272,7 @@ describe('模板库灰度会话', () => { setLauncherView, rememberRecentWorkspace, setStatus: vi.fn(), - setAgentChatProjectPath: vi.fn(), + showTransientNotice: vi.fn(), }); const library = useTemplateLibrary({ userId: 'user-a', diff --git a/apps/ai-game-creator-shell/tests/workbenchThemeContrast.test.ts b/apps/ai-game-creator-shell/tests/workbenchThemeContrast.test.ts index 8cde18820..d634040ef 100644 --- a/apps/ai-game-creator-shell/tests/workbenchThemeContrast.test.ts +++ b/apps/ai-game-creator-shell/tests/workbenchThemeContrast.test.ts @@ -32,17 +32,18 @@ function getCssBlock(source: string, selector: string) { function getCssVariable(block: string, variableName: string) { const match = block.match(new RegExp(`${variableName}:\\s*([^;]+);`)); expect(match, `${variableName} should exist`).not.toBeNull(); - return match![1].trim(); + return (match?.[1] ?? '').trim(); } function parseCssColor(source: string): Rgba { const value = source.trim(); const hex = value.match(/^#([\da-f]{6})$/i); if (hex) { + const hexValue = hex[1] ?? ''; return [ - Number.parseInt(hex[1].slice(0, 2), 16), - Number.parseInt(hex[1].slice(2, 4), 16), - Number.parseInt(hex[1].slice(4, 6), 16), + Number.parseInt(hexValue.slice(0, 2), 16), + Number.parseInt(hexValue.slice(2, 4), 16), + Number.parseInt(hexValue.slice(4, 6), 16), 1, ]; } @@ -51,7 +52,7 @@ function parseCssColor(source: string): Rgba { if (!functional) { throw new Error(`unsupported color: ${value}`); } - const parts = functional[1] + const parts = (functional[1] ?? '') .replace('/', ' ') .split(/[,\s]+/) .filter(Boolean); @@ -87,7 +88,11 @@ function contrastRatio(first: Rgba, second: Rgba) { ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; }); - return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722; + return ( + (channels[0] ?? 0) * 0.2126 + + (channels[1] ?? 0) * 0.7152 + + (channels[2] ?? 0) * 0.0722 + ); }; const firstLuminance = luminance(first); diff --git a/apps/ai-game-creator-shell/tests/workspaceLauncherManifestMerge.test.tsx b/apps/ai-game-creator-shell/tests/workspaceLauncherManifestMerge.test.tsx index ea48aa7f5..f2b693b74 100644 --- a/apps/ai-game-creator-shell/tests/workspaceLauncherManifestMerge.test.tsx +++ b/apps/ai-game-creator-shell/tests/workspaceLauncherManifestMerge.test.tsx @@ -68,7 +68,7 @@ function manifestWithAsset(assetId: string | null): GameCreationAppManifest { value.assets = [ { id: assetId, - kind: 'upload', + kind: 'image', mediaType: 'image/png', localPath: `assets/uploads/${assetId}.png`, source: { kind: 'uploaded' }, @@ -83,7 +83,10 @@ let diskManifest = manifestWithAsset(null); function installInvokeMock() { const invoke = vi.fn( - async (command: string, args?: Record) => { + async ( + command: string, + args?: Record, + ): Promise => { switch (command) { case 'read_game_creator_app_config': return { config: { selectedModelId: 'quality' } };