Files
Genarrative/apps/ai-game-creator-shell/tests/useTemplateLibrary.test.tsx
T
kdletters 623e007fae
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 7m36s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 7m37s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 7m42s
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 7m51s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m55s
Project CI / AI game creator shell Rust crates (push) Successful in 4m15s
Project CI / Repository checks (push) Successful in 4m45s
Project CI / Frontend tests (push) Successful in 7m47s
Project CI / Native shell tests (push) Successful in 9m33s
Project CI / Backend tests (push) Successful in 10m9s
Project CI / AI game creator shell web tests (push) Successful in 4m38s
完善客户端发布渠道并接入模板库灰度
区分发布渠道与系统,支持 dev、release 和自定义渠道
允许网站通过服务端配置选择客户端下载检测渠道
接入模板库灰度权限并阻断退出和切号后的异步操作
补齐发布、下载、灰度与会话竞态测试及当前规范
2026-09-20 12:12:49 +08:00

307 lines
11 KiB
TypeScript

// @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<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((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<boolean>();
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<boolean>();
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<GameTemplateLibrarySnapshot>();
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<unknown>();
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<unknown>;
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<unknown>();
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<unknown>;
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<unknown>();
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<unknown>;
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();
},
);
});