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