1e186369c9
Project CI / AI game creator shell Rust crates (push) Successful in 1m24s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m56s
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/446 Co-authored-by: Linghong <ink29535@proton.me> Co-committed-by: Linghong <ink29535@proton.me>
333 lines
10 KiB
TypeScript
333 lines
10 KiB
TypeScript
// @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<T>() {
|
|
let resolve!: (value: T) => void;
|
|
return {
|
|
promise: new Promise<T>((done) => {
|
|
resolve = done;
|
|
}),
|
|
resolve: (value: T) => resolve(value),
|
|
};
|
|
}
|
|
afterEach(() => {
|
|
cleanup();
|
|
delete window.__TAURI__;
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
function mount(
|
|
capture: () => Promise<unknown> = async () => context,
|
|
preview: (path: string) => Promise<unknown> = 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<string, unknown>) => {
|
|
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<unknown>();
|
|
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<unknown>();
|
|
const { result, unmount, records, invoke } = mount(
|
|
undefined,
|
|
() => preview.promise,
|
|
);
|
|
let pending!: Promise<void>;
|
|
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<unknown>();
|
|
const { result, invoke, manifest, records } = mount(
|
|
undefined,
|
|
async (path) => (path === 'C:/old' ? oldPreview.promise : null),
|
|
);
|
|
let old!: Promise<void>;
|
|
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<unknown>();
|
|
const { result, invoke, manifest, records } = mount(
|
|
undefined,
|
|
() => pending.promise,
|
|
);
|
|
let current = true;
|
|
let entry!: Promise<void>;
|
|
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<typeof context>();
|
|
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();
|
|
});
|
|
});
|