// @vitest-environment jsdom import { act, cleanup, renderHook } from '@testing-library/react'; import { StrictMode } from 'react'; import { afterEach, expect, it, vi } from 'vitest'; import { createGameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp'; import type { TauriInvoke } from '../src/app/types'; import { useHomeProjectCreation } from '../src/features/app-shell/useHomeProjectCreation'; import { beginProjectOpenAnalytics, beginUiSaveAnalytics, } from '../src/services/clientAnalytics'; const context = { route: { user_id: 'user-a', destination_origin: 'https://a.example' }, editor_session_id: 'session-a', client_version: '1', }; function deferred() { let resolve!: (value: T) => void; return { promise: new Promise((done) => { resolve = done; }), resolve: (value: T) => resolve(value), }; } afterEach(() => { cleanup(); delete window.__TAURI__; vi.restoreAllMocks(); }); function mount( capture: () => Promise = async () => context, preview: (path: string) => Promise = async () => null, ) { vi.spyOn(crypto, 'randomUUID').mockReturnValue( '12345678-1234-4234-8234-123456789012', ); const manifest = createGameCreationAppManifest('project', '项目'); const invoke = vi.fn( async (command: string, args?: Record) => { if (command === 'capture_analytics_context') return capture(); if (command === 'get_local_game_preview_status') return preview(String(args?.projectPath)); if (command === 'inspect_local_project_directory') return { exists: true, isDirectory: true, isGameCreatorProject: true }; if (command === 'get_local_game_manifest') return manifest; if (command === 'pick_local_project_directory') return 'C:/picker'; if (command === 'init_local_game_project') return { projectPath: args?.projectPath, manifest }; if (command === 'get_local_game_project_revision') return { revision: 1 }; return null; }, ); window.__TAURI__ = { core: { invoke } } as unknown as typeof window.__TAURI__; const hook = renderHook( () => useHomeProjectCreation({ setStatus: vi.fn(), setLauncherView: vi.fn(), rememberRecentWorkspace: vi.fn(), }), { wrapper: StrictMode }, ); return { ...hook, invoke, manifest, records: () => invoke.mock.calls.filter( ([command]) => command === 'record_analytics_project_open', ), }; } it.each(['recent', 'picker', 'create'] as const)( '成功进入项目记录真实 %s 来源', async (source) => { const { result, records } = mount(); await act(async () => { if (source === 'picker') await result.current.pickAndOpenProject(); else await result.current.openProject( 'C:/project', source === 'create' ? 'create' : 'open', ); }); expect(records()).toHaveLength(1); expect(records()[0][1]).toMatchObject({ context, openSource: source }); }, ); it('身份抓取挂起不阻塞打开,成功时间在抓取完成前冻结', async () => { const captured = deferred(); const { result, records } = mount(() => captured.promise); await act(async () => { await result.current.openProject('C:/project', 'open'); }); expect(result.current.currentProjectContext?.projectPath).toBe('C:/project'); const latestSuccessTime = Date.now(); expect(records()).toHaveLength(0); await act(async () => { captured.resolve(context); }); expect(records()).toHaveLength(1); expect(Date.parse(String(records()[0][1]?.eventTime))).toBeLessThanOrEqual( latestSuccessTime, ); }); it('预览核验挂起时卸载,不记录从未进入的工作区', async () => { const preview = deferred(); const { result, unmount, records, invoke } = mount( undefined, () => preview.promise, ); let pending!: Promise; await act(async () => { pending = result.current.openProject('C:/old', 'open'); }); unmount(); await act(async () => { preview.resolve(null); await pending; }); expect(records()).toHaveLength(0); expect( invoke.mock.calls.filter( ([command]) => command === 'record_analytics_project_leave', ), ).toHaveLength(0); }); it('StrictMode 重放不产生离开,真正卸载清除已进入的项目', async () => { const { result, unmount, invoke } = mount(); await act(async () => { await result.current.openProject('C:/project', 'open'); }); const leaves = () => invoke.mock.calls.filter( ([command]) => command === 'record_analytics_project_leave', ); expect(leaves()).toHaveLength(0); unmount(); await act(async () => {}); expect(leaves()).toEqual([ ['record_analytics_project_leave', { projectPath: 'C:/project' }], ]); }); it('页面关闭通知尽力清理,随后卸载不会重复通知', async () => { const { result, unmount, invoke } = mount(); await act(async () => { await result.current.openProject('C:/project', 'open'); }); window.dispatchEvent(new Event('pagehide')); unmount(); await act(async () => {}); expect( invoke.mock.calls.filter( ([command]) => command === 'record_analytics_project_leave', ), ).toHaveLength(1); }); it('同项目再次显式打开是新操作,重渲染不会产生额外打开', async () => { const { result, rerender, records } = mount(); vi.mocked(crypto.randomUUID) .mockReturnValueOnce('12345678-1234-4234-8234-123456789011') .mockReturnValueOnce('12345678-1234-4234-8234-123456789012'); await act(async () => { await result.current.openProject('C:/project', 'open'); }); rerender(); expect(records()).toHaveLength(1); await act(async () => { await result.current.openProject('C:/project', 'open'); }); expect(records()).toHaveLength(2); expect(records()[0][1]?.operationId).not.toBe(records()[1][1]?.operationId); }); it('身份抓取和记录写入失败静默,不改变成功打开', async () => { const { result, records, invoke } = mount(async () => { throw new Error('offline'); }); await act(async () => { await result.current.openProject('C:/project', 'open'); }); expect(result.current.currentProjectContext?.projectPath).toBe('C:/project'); expect(records()).toHaveLength(0); const failing = vi.fn(async (command: string) => { if (command === 'capture_analytics_context') return context; throw new Error('write'); }); beginProjectOpenAnalytics(failing as TauriInvoke, 'recent')?.record( 'C:/project', ); await act(async () => {}); expect(failing).toHaveBeenCalledWith( 'record_analytics_project_open', expect.anything(), ); expect(invoke).toHaveBeenCalledWith('capture_analytics_context'); }); it('较晚的导航获采纳,旧预览核验迟到不记录打开', async () => { const oldPreview = deferred(); const { result, invoke, manifest, records } = mount( undefined, async (path) => (path === 'C:/old' ? oldPreview.promise : null), ); let old!: Promise; await act(async () => { old = result.current.enterCreatedTemplateProject( { projectPath: 'C:/old', manifestPath: '', manifest }, () => true, beginProjectOpenAnalytics(invoke as TauriInvoke, 'create'), ); }); await act(async () => { await result.current.enterCreatedTemplateProject( { projectPath: 'C:/new', manifestPath: '', manifest }, () => true, beginProjectOpenAnalytics(invoke as TauriInvoke, 'create'), ); oldPreview.resolve(null); await old; }); expect(result.current.currentProjectContext?.projectPath).toBe('C:/new'); expect(records().map(([, args]) => args?.projectPath)).toEqual(['C:/new']); }); it('模板权限失效后不记录被舍弃的导航,同次成功通知只记一次', async () => { const pending = deferred(); const { result, invoke, manifest, records } = mount( undefined, () => pending.promise, ); let current = true; let entry!: Promise; const action = beginProjectOpenAnalytics(invoke as TauriInvoke, 'create'); await act(async () => { entry = result.current.enterCreatedTemplateProject( { projectPath: 'C:/old', manifestPath: '', manifest }, () => current, action, ); }); await act(async () => { current = false; pending.resolve(null); await entry; }); expect(records()).toHaveLength(0); action?.record('C:/accepted'); action?.record('C:/accepted'); await act(async () => {}); expect(records()).toHaveLength(1); }); it('freezes UI save identity and completion time while context delivery is delayed', async () => { const captured = deferred(); const invoke = vi.fn(async (command: string) => command === 'capture_analytics_context' ? captured.promise : undefined, ); const now = vi .spyOn(Date.prototype, 'toISOString') .mockReturnValue('2026-09-21T01:00:00.000Z'); const analytics = beginUiSaveAnalytics( invoke as TauriInvoke, 'C:/project', 'manual', ); analytics?.record(false); now.mockReturnValue('2026-09-21T02:00:00.000Z'); analytics?.record(true); captured.resolve(context); await act(async () => { await captured.promise; }); const calls = invoke.mock.calls.filter( ([command]) => command === 'record_analytics_ui_save', ); expect(calls).toHaveLength(1); expect(invoke).toHaveBeenCalledWith( 'record_analytics_ui_save', expect.objectContaining({ context, changed: false, eventTime: '2026-09-21T01:00:00.000Z', }), ); }); it('isolates UI save analytics UUID and sync/async bridge failures', async () => { const throwing = vi.fn(() => { throw new Error('bridge unavailable'); }); expect(() => beginUiSaveAnalytics( throwing as TauriInvoke, 'C:/project', 'manual', )?.record(true), ).not.toThrow(); const rejected = vi.fn().mockRejectedValue(new Error('bridge rejected')); beginUiSaveAnalytics(rejected as TauriInvoke, 'C:/project', 'manual')?.record( true, ); const recordFailure = vi.fn((command: string) => { if (command === 'capture_analytics_context') return Promise.resolve(context); throw new Error('record failed'); }); beginUiSaveAnalytics( recordFailure as TauriInvoke, 'C:/project', 'manual', )?.record(true); vi.spyOn(crypto, 'randomUUID').mockImplementation(() => { throw new Error('no UUID'); }); expect( beginUiSaveAnalytics(rejected as TauriInvoke, 'C:/project', 'manual'), ).toBeNull(); await act(async () => { await Promise.resolve(); }); });