338 lines
9.3 KiB
TypeScript
338 lines
9.3 KiB
TypeScript
import { beforeEach, expect, test, vi } from 'vitest';
|
|
|
|
const { fetchWithApiAuthMock, requestJsonMock } = vi.hoisted(() => ({
|
|
fetchWithApiAuthMock: vi.fn(),
|
|
requestJsonMock: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../apiClient', () => ({
|
|
fetchWithApiAuth: fetchWithApiAuthMock,
|
|
requestJson: requestJsonMock,
|
|
}));
|
|
|
|
import type { VisualNovelRunSnapshot } from '../../../packages/shared/src/contracts/visualNovel';
|
|
import {
|
|
buildVisualNovelRuntimeCheckpoint,
|
|
buildVisualNovelSaveArchiveState,
|
|
getVisualNovelHistory,
|
|
getVisualNovelRun,
|
|
listVisualNovelGallery,
|
|
listVisualNovelSaveArchives,
|
|
putVisualNovelRuntimeSnapshot,
|
|
regenerateVisualNovelRun,
|
|
resumeVisualNovelSaveArchive,
|
|
startVisualNovelRun,
|
|
streamVisualNovelRuntimeAction,
|
|
type VisualNovelRuntimeStreamOptions,
|
|
} from './visualNovelRuntimeClient';
|
|
|
|
function createMockRun(
|
|
overrides: Partial<VisualNovelRunSnapshot> = {},
|
|
): VisualNovelRunSnapshot {
|
|
return {
|
|
runId: 'vn-run-route-1',
|
|
ownerUserId: 'user-1',
|
|
profileId: 'vn-profile-1',
|
|
mode: 'test',
|
|
status: 'active',
|
|
currentSceneId: 'scene-1',
|
|
currentPhaseId: 'phase-1',
|
|
visibleCharacterIds: [],
|
|
flags: {},
|
|
metrics: {},
|
|
history: [],
|
|
availableChoices: [],
|
|
textModeEnabled: false,
|
|
createdAt: '2026-05-07T09:00:00.000Z',
|
|
updatedAt: '2026-05-07T09:00:00.000Z',
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function createSseResponse(bodyText: string) {
|
|
return new Response(
|
|
new ReadableStream({
|
|
start(controller) {
|
|
controller.enqueue(new TextEncoder().encode(bodyText));
|
|
controller.close();
|
|
},
|
|
}),
|
|
{
|
|
headers: {
|
|
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
beforeEach(() => {
|
|
fetchWithApiAuthMock.mockReset();
|
|
requestJsonMock.mockReset();
|
|
});
|
|
|
|
test('listVisualNovelGallery reads public gallery without auth refresh coupling', async () => {
|
|
requestJsonMock.mockResolvedValueOnce({ works: [] });
|
|
|
|
await listVisualNovelGallery();
|
|
|
|
expect(requestJsonMock).toHaveBeenCalledWith(
|
|
'/api/runtime/visual-novel/gallery',
|
|
expect.objectContaining({ method: 'GET' }),
|
|
'读取视觉小说公开作品列表失败',
|
|
expect.objectContaining({
|
|
retry: expect.objectContaining({ maxRetries: 1 }),
|
|
skipAuth: true,
|
|
skipRefresh: true,
|
|
}),
|
|
);
|
|
});
|
|
|
|
test('startVisualNovelRun uses the visual novel runtime work route', async () => {
|
|
requestJsonMock.mockResolvedValueOnce({ run: createMockRun() });
|
|
|
|
await startVisualNovelRun('vn-profile-1', {
|
|
profileId: 'vn-profile-1',
|
|
mode: 'test',
|
|
});
|
|
|
|
expect(requestJsonMock).toHaveBeenCalledWith(
|
|
'/api/runtime/visual-novel/works/vn-profile-1/runs',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ profileId: 'vn-profile-1', mode: 'test' }),
|
|
}),
|
|
'启动视觉小说运行失败',
|
|
expect.objectContaining({
|
|
retry: expect.objectContaining({ maxRetries: 1, retryUnsafeMethods: true }),
|
|
timeoutMs: 15000,
|
|
}),
|
|
);
|
|
});
|
|
|
|
test('getVisualNovelRun and getVisualNovelHistory use encoded runtime run routes', async () => {
|
|
requestJsonMock
|
|
.mockResolvedValueOnce({ run: createMockRun() })
|
|
.mockResolvedValueOnce({ entries: [] });
|
|
|
|
await getVisualNovelRun('vn/run-1');
|
|
await getVisualNovelHistory('vn/run-1');
|
|
|
|
expect(requestJsonMock.mock.calls[0]).toEqual([
|
|
'/api/runtime/visual-novel/runs/vn%2Frun-1',
|
|
expect.objectContaining({ method: 'GET' }),
|
|
'读取视觉小说运行快照失败',
|
|
expect.objectContaining({
|
|
retry: expect.objectContaining({ maxRetries: 1 }),
|
|
}),
|
|
]);
|
|
expect(requestJsonMock.mock.calls[1]).toEqual([
|
|
'/api/runtime/visual-novel/runs/vn%2Frun-1/history',
|
|
expect.objectContaining({ method: 'GET' }),
|
|
'读取视觉小说历史失败',
|
|
expect.objectContaining({
|
|
retry: expect.objectContaining({ maxRetries: 1 }),
|
|
}),
|
|
]);
|
|
});
|
|
|
|
test('streamVisualNovelRuntimeAction posts to the SSE action stream route', async () => {
|
|
const response = createSseResponse(
|
|
[
|
|
'event: raw_text',
|
|
'data: {"text":"临时文本"}',
|
|
'',
|
|
'event: complete',
|
|
'data: {"run":' + JSON.stringify(createMockRun()) + '}',
|
|
'',
|
|
'',
|
|
].join('\n'),
|
|
);
|
|
fetchWithApiAuthMock.mockResolvedValueOnce(response);
|
|
|
|
const result = await streamVisualNovelRuntimeAction(
|
|
'vn-run-route-1',
|
|
{
|
|
actionKind: 'free_text',
|
|
text: '检查广播柜',
|
|
clientEventId: 'client-event-1',
|
|
},
|
|
{
|
|
onEvent: vi.fn(),
|
|
} satisfies VisualNovelRuntimeStreamOptions,
|
|
);
|
|
|
|
expect(fetchWithApiAuthMock).toHaveBeenCalledWith(
|
|
'/api/runtime/visual-novel/runs/vn-run-route-1/actions/stream',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
actionKind: 'free_text',
|
|
text: '检查广播柜',
|
|
clientEventId: 'client-event-1',
|
|
}),
|
|
signal: undefined,
|
|
}),
|
|
expect.objectContaining({
|
|
skipAuth: undefined,
|
|
skipRefresh: undefined,
|
|
}),
|
|
);
|
|
expect(result).toMatchObject({ runId: 'vn-run-route-1' });
|
|
});
|
|
|
|
test('regenerateVisualNovelRun uses the history regenerate route', async () => {
|
|
requestJsonMock.mockResolvedValueOnce({ run: createMockRun() });
|
|
|
|
await regenerateVisualNovelRun('vn-run-route-1', {
|
|
historyEntryId: 'vn-history-1',
|
|
clientEventId: 'client-event-2',
|
|
});
|
|
|
|
expect(requestJsonMock).toHaveBeenCalledWith(
|
|
'/api/runtime/visual-novel/runs/vn-run-route-1/regenerate',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
historyEntryId: 'vn-history-1',
|
|
clientEventId: 'client-event-2',
|
|
}),
|
|
}),
|
|
'重生成视觉小说历史失败',
|
|
expect.objectContaining({
|
|
retry: expect.objectContaining({ maxRetries: 1, retryUnsafeMethods: true }),
|
|
}),
|
|
);
|
|
});
|
|
|
|
test('listVisualNovelSaveArchives and resumeVisualNovelSaveArchive use platform archive routes', async () => {
|
|
requestJsonMock.mockResolvedValueOnce({
|
|
entries: [
|
|
{
|
|
worldKey: 'visual-novel:one',
|
|
ownerUserId: 'user-1',
|
|
profileId: 'vn-profile-1',
|
|
worldType: 'visual-novel',
|
|
worldName: '雪线电台',
|
|
subtitle: '风雪站台',
|
|
summaryText: '第 2 回合',
|
|
coverImageSrc: null,
|
|
lastPlayedAt: '2026-05-07T09:00:00.000Z',
|
|
},
|
|
],
|
|
});
|
|
await listVisualNovelSaveArchives('vn-profile-1');
|
|
|
|
expect(requestJsonMock).toHaveBeenCalledWith(
|
|
'/api/profile/save-archives',
|
|
expect.objectContaining({ method: 'GET' }),
|
|
'读取视觉小说存档失败',
|
|
expect.objectContaining({
|
|
retry: expect.objectContaining({ maxRetries: 1 }),
|
|
}),
|
|
);
|
|
|
|
requestJsonMock.mockResolvedValueOnce({
|
|
entry: {
|
|
worldKey: 'visual-novel:one',
|
|
},
|
|
snapshot: {
|
|
version: 2,
|
|
savedAt: '2026-05-07T09:00:00.000Z',
|
|
bottomTab: 'adventure',
|
|
currentStory: null,
|
|
gameState: {
|
|
worldType: 'VISUAL_NOVEL',
|
|
},
|
|
},
|
|
});
|
|
await resumeVisualNovelSaveArchive('visual-novel:one');
|
|
|
|
expect(requestJsonMock).toHaveBeenCalledWith(
|
|
'/api/profile/save-archives/visual-novel%3Aone',
|
|
expect.objectContaining({ method: 'POST' }),
|
|
'恢复视觉小说存档失败',
|
|
expect.objectContaining({
|
|
retry: expect.objectContaining({
|
|
maxRetries: 1,
|
|
retryUnsafeMethods: true,
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
test('putVisualNovelRuntimeSnapshot only submits platform checkpoint metadata', async () => {
|
|
requestJsonMock.mockResolvedValueOnce({ ok: true });
|
|
|
|
await putVisualNovelRuntimeSnapshot({
|
|
sessionId: 'vn-run-route-1',
|
|
bottomTab: 'adventure',
|
|
savedAt: '2026-05-07T09:00:00.000Z',
|
|
});
|
|
|
|
expect(requestJsonMock).toHaveBeenCalledWith(
|
|
'/api/runtime/save/snapshot',
|
|
expect.objectContaining({
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
sessionId: 'vn-run-route-1',
|
|
bottomTab: 'adventure',
|
|
savedAt: '2026-05-07T09:00:00.000Z',
|
|
}),
|
|
}),
|
|
'保存视觉小说存档失败',
|
|
expect.objectContaining({
|
|
retry: expect.objectContaining({
|
|
maxRetries: 1,
|
|
retryUnsafeMethods: true,
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
test('buildVisualNovelRuntimeCheckpoint maps run id into session id', () => {
|
|
expect(
|
|
buildVisualNovelRuntimeCheckpoint({
|
|
run: createMockRun(),
|
|
savedAt: '2026-05-07T09:00:00.000Z',
|
|
}),
|
|
).toEqual({
|
|
sessionId: 'vn-run-route-1',
|
|
bottomTab: 'adventure',
|
|
savedAt: '2026-05-07T09:00:00.000Z',
|
|
});
|
|
});
|
|
|
|
test('buildVisualNovelSaveArchiveState only uses runtime identifiers and hashes', () => {
|
|
expect(
|
|
buildVisualNovelSaveArchiveState(
|
|
createMockRun({
|
|
history: [
|
|
{
|
|
entryId: 'vn-history-1',
|
|
runId: 'vn-run-route-1',
|
|
turnIndex: 3,
|
|
source: 'assistant',
|
|
actionText: '继续',
|
|
steps: [],
|
|
snapshotBeforeHash: 'before-hash',
|
|
snapshotAfterHash: 'after-hash',
|
|
createdAt: '2026-05-07T09:00:00.000Z',
|
|
},
|
|
],
|
|
}),
|
|
),
|
|
).toEqual({
|
|
runtimeKind: 'visual-novel',
|
|
profileId: 'vn-profile-1',
|
|
runId: 'vn-run-route-1',
|
|
currentSceneId: 'scene-1',
|
|
currentPhaseId: 'phase-1',
|
|
historyCursor: 3,
|
|
snapshotHash: 'after-hash',
|
|
});
|
|
});
|