Files
Genarrative/src/services/visual-novel-runtime/visualNovelRuntimeClient.test.ts

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',
});
});