1
This commit is contained in:
@@ -0,0 +1,305 @@
|
||||
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 {
|
||||
buildVisualNovelRuntimeCheckpoint,
|
||||
buildVisualNovelSaveArchiveState,
|
||||
type VisualNovelRuntimeStreamOptions,
|
||||
listVisualNovelGallery,
|
||||
listVisualNovelSaveArchives,
|
||||
putVisualNovelRuntimeSnapshot,
|
||||
regenerateVisualNovelRun,
|
||||
resumeVisualNovelSaveArchive,
|
||||
startVisualNovelRun,
|
||||
streamVisualNovelRuntimeAction,
|
||||
} from './visualNovelRuntimeClient';
|
||||
import type { VisualNovelRunSnapshot } from '../../../packages/shared/src/contracts/visualNovel';
|
||||
|
||||
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('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(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',
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user