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 和自定义渠道 允许网站通过服务端配置选择客户端下载检测渠道 接入模板库灰度权限并阻断退出和切号后的异步操作 补齐发布、下载、灰度与会话竞态测试及当前规范
307 lines
11 KiB
TypeScript
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();
|
|
},
|
|
);
|
|
});
|