// @vitest-environment jsdom import { act, cleanup, renderHook, waitFor } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createGameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp'; import { useHomeProjectCreation } from '../src/features/app-shell/useHomeProjectCreation'; import type { GameTemplateEntry, GameTemplateLibrarySnapshot, } from '../src/features/template-library/templateLibraryModel'; import { useTemplateLibrary } from '../src/features/template-library/useTemplateLibrary'; import { beginPlatformSessionClearTransition, resetPlatformSessionStateForTests, } from '../src/services/platformSession'; const invoke = vi.hoisted(() => vi.fn()); vi.mock('../src/app/tauri', () => ({ resolveTauriInvoke: () => invoke })); const template: GameTemplateEntry = { id: 'demo', title: '演示', summary: '', tags: [], runtime: 'html', engine: 'phaser', engineVersion: '4', templateVersion: '1', updatedAt: '', entry: 'index.html', zipUrl: 'https://example.invalid/demo.zip', zipSizeBytes: 1, zipSha256: 'a'.repeat(64), coverUrl: '', coverWidth: 100, coverHeight: 100, installed: true, installedVersion: '1', installedAtMillis: 1, }; const snapshot = (id = 'demo'): GameTemplateLibrarySnapshot => ({ schemaVersion: 'agc-template-library.v1', library: 'official', libraryVersion: 1, updatedAt: '', fetchedAtMillis: 1, source: 'cache', templates: [{ ...template, id }], }); function deferred() { let resolve!: (value: T) => void; const promise = new Promise((done) => { resolve = done; }); return { promise, resolve }; } const onProjectCreated = vi.fn(); const mount = () => renderHook(({ userId }) => useTemplateLibrary({ userId, onProjectCreated }), { initialProps: { userId: 'user-a' }, }); beforeEach(() => { resetPlatformSessionStateForTests(); invoke.mockReset(); onProjectCreated.mockReset(); }); afterEach(cleanup); describe('模板库灰度会话', () => { it('等待权威权限时不读取清单,拒绝后也不能调用下载', async () => { const access = deferred(); invoke.mockImplementation(() => access.promise); const { result } = mount(); expect(result.current.enabled).toBe(false); expect(invoke.mock.calls.map(([command]) => command)).toEqual([ 'get_game_template_library_access', ]); await act(async () => { access.resolve(false); }); await expect(result.current.downloadTemplate(template)).rejects.toThrow( '暂未', ); expect(result.current.snapshot).toBeNull(); expect(invoke).toHaveBeenCalledTimes(1); }); it('命中后读取清单,窗口重新聚焦失去权限就清空缓存投影', async () => { let allowed = true; invoke.mockImplementation(async (command) => command === 'get_game_template_library_access' ? allowed : snapshot(), ); const { result } = mount(); await waitFor(() => expect(result.current.status).toBe('ready')); expect(result.current.enabled).toBe(true); expect(result.current.templates).toHaveLength(1); allowed = false; await act(async () => { window.dispatchEvent(new Event('focus')); }); expect(result.current.enabled).toBe(false); expect(result.current.templates).toEqual([]); expect(result.current.snapshot).toBeNull(); expect( invoke.mock.calls.filter( ([command]) => command === 'fetch_game_template_library', ), ).toHaveLength(1); }); it('账号切换后丢弃前一账号迟到的允许结果', async () => { const access = deferred(); invoke .mockImplementationOnce(() => access.promise) .mockResolvedValue(false); const { result, rerender } = mount(); rerender({ userId: 'user-b' }); await act(async () => { access.resolve(true); }); expect(result.current.enabled).toBe(false); expect( invoke.mock.calls.filter( ([command]) => command === 'fetch_game_template_library', ), ).toHaveLength(0); }); it('账号切换后旧清单不能覆盖当前账号的清单', async () => { const oldManifest = deferred(); let fetches = 0; invoke.mockImplementation(async (command) => { if (command === 'get_game_template_library_access') return true; return ++fetches === 1 ? oldManifest.promise : snapshot('user-b-template'); }); const { result, rerender } = mount(); await waitFor(() => expect(fetches).toBe(1)); rerender({ userId: 'user-b' }); await waitFor(() => expect(result.current.templates[0]?.id).toBe('user-b-template'), ); await act(async () => { oldManifest.resolve(snapshot('user-a-template')); }); expect(result.current.templates[0]?.id).toBe('user-b-template'); }); it.each(['download', 'create'] as const)( '账号切换后迟到的%s结果不能写状态或跳转', async (operation) => { const completion = deferred(); let allowed = true; invoke.mockImplementation(async (command) => { if (command === 'get_game_template_library_access') return allowed; if (command === 'fetch_game_template_library') return snapshot(); return completion.promise; }); const { result, rerender } = mount(); await waitFor(() => expect(result.current.status).toBe('ready')); let pending!: Promise; act(() => { pending = ( operation === 'create' ? result.current.createProjectFromTemplate(template) : result.current.downloadTemplate(template) ).catch((error) => error); }); allowed = false; rerender({ userId: 'user-b' }); await act(async () => { completion.resolve({ projectPath: '/old', templateVersion: '1' }); await pending; }); expect(await pending).toBeInstanceOf(Error); expect(onProjectCreated).not.toHaveBeenCalled(); expect(result.current.enabled).toBe(false); expect(result.current.notice).toBe(''); expect(result.current.busyTemplateId).toBeNull(); }, ); it('原生命令拒绝权限后立即关闭入口,已安装模板不能绕过', async () => { invoke.mockImplementation(async (command) => { if (command === 'get_game_template_library_access') return true; if (command === 'fetch_game_template_library') return snapshot(); throw new Error('template-library-unavailable: 模板库暂未开放'); }); const { result } = mount(); await waitFor(() => expect(result.current.status).toBe('ready')); await act(async () => { await expect( result.current.createProjectFromTemplate(template), ).rejects.toThrow('暂未开放'); }); expect(result.current.enabled).toBe(false); expect(result.current.snapshot).toBeNull(); expect(onProjectCreated).not.toHaveBeenCalled(); }); it('检查权限失败时关闭入口而不使用先前允许结果', async () => { invoke.mockImplementation(async (command) => command === 'get_game_template_library_access' ? true : snapshot(), ); const { result } = mount(); await waitFor(() => expect(result.current.status).toBe('ready')); invoke.mockRejectedValueOnce(new Error('offline')); await act(async () => { await result.current.refresh(); }); expect(result.current.enabled).toBe(false); expect(result.current.templates).toEqual([]); }); it('退出登录开始时即使 userId 未变也清空权限并停止旧建项回调', async () => { const completion = deferred(); invoke.mockImplementation(async (command) => { if (command === 'get_game_template_library_access') return true; if (command === 'fetch_game_template_library') return snapshot(); return completion.promise; }); const { result } = mount(); await waitFor(() => expect(result.current.status).toBe('ready')); let pending!: Promise; act(() => { pending = result.current .createProjectFromTemplate(template) .catch((error) => error); }); act(() => { beginPlatformSessionClearTransition(); }); expect(result.current.enabled).toBe(false); const count = invoke.mock.calls.length; await act(async () => { await result.current.refresh(); }); expect(invoke).toHaveBeenCalledTimes(count); await act(async () => { completion.resolve({ projectPath: '/old' }); await pending; }); expect(onProjectCreated).not.toHaveBeenCalled(); }); it.each(['get_local_game_project_revision', 'get_local_game_preview_status'])( '退出登录时 %s 的旧结果不能进入项目或登记最近项目', async (delayedCommand) => { const delayed = deferred(); const manifest = createGameCreationAppManifest( 'template-project', '模板项目', ); invoke.mockImplementation(async (command) => { if (command === 'get_game_template_library_access') return true; if (command === 'fetch_game_template_library') return snapshot(); if (command === 'create_automatic_local_game_project_from_template') return { projectPath: 'C:/test/template-project', manifest }; if (command === delayedCommand) return delayed.promise; if (command === 'get_local_game_project_revision') return { revision: 1 }; if (command === 'get_local_game_preview_status') return { status: 'stopped' }; throw new Error(command); }); const setLauncherView = vi.fn(); const rememberRecentWorkspace = vi.fn(); const { result } = renderHook(() => { const home = useHomeProjectCreation({ setLauncherView, rememberRecentWorkspace, setStatus: vi.fn(), setAgentChatProjectPath: vi.fn(), }); const library = useTemplateLibrary({ userId: 'user-a', onProjectCreated: (created, isCurrent) => home.enterCreatedTemplateProject(created, isCurrent), }); return { home, library }; }); await waitFor(() => expect(result.current.library.status).toBe('ready')); let pending!: Promise; act(() => { pending = result.current.library.createProjectFromTemplate(template); }); await waitFor(() => expect( invoke.mock.calls.some(([command]) => command === delayedCommand), ).toBe(true), ); act(() => { beginPlatformSessionClearTransition(); }); await act(async () => { delayed.resolve({ revision: 1, status: 'stopped' }); await pending; }); expect(result.current.home.currentProjectContext).toBeNull(); expect(setLauncherView).not.toHaveBeenCalled(); expect(rememberRecentWorkspace).not.toHaveBeenCalled(); }, ); });