/* @vitest-environment jsdom */ import { act, render, screen, waitFor } from '@testing-library/react'; import { beforeEach, expect, test, vi } from 'vitest'; import { readSavedSettings } from '../persistence/gameSettingsStorage'; import type { GameState, StoryMoment } from '../types'; import type { BottomTab } from '../types/navigation'; import { useRpgSessionPersistence } from './rpg-session'; import { useGameSettings } from './useGameSettings'; const storageMocks = vi.hoisted(() => ({ getSettings: vi.fn(), putSettings: vi.fn(), getSaveSnapshot: vi.fn(), putSaveSnapshot: vi.fn(), deleteSaveSnapshot: vi.fn(), })); vi.mock('../services/rpg-entry', () => ({ getRpgProfileSettings: storageMocks.getSettings, putRpgProfileSettings: storageMocks.putSettings, })); vi.mock('../services/rpg-runtime', () => ({ getRuntimeSessionId: (gameState: Pick) => gameState.runtimeSessionId?.trim() || 'runtime-main', rpgSnapshotClient: { getSnapshot: storageMocks.getSaveSnapshot, putSnapshot: storageMocks.putSaveSnapshot, deleteSnapshot: storageMocks.deleteSaveSnapshot, }, })); function SettingsHarness({ authenticatedUserId, }: { authenticatedUserId: string | null; }) { const settings = useGameSettings(authenticatedUserId); return (
{settings.musicVolume.toFixed(2)}
); } function PersistenceHarness({ authenticatedUserId, gameState = {} as GameState, currentStory = null as StoryMoment | null, }: { authenticatedUserId: string | null; gameState?: GameState; currentStory?: StoryMoment | null; }) { const persistence = useRpgSessionPersistence({ authenticatedUserId, gameState, bottomTab: 'adventure' as BottomTab, currentStory, isLoading: false, setGameState: () => {}, setBottomTab: () => {}, hydrateStoryState: () => {}, resetStoryState: () => {}, }); return (
{persistence.hasSavedGame ? 'yes' : 'no'}
{persistence.isHydratingSnapshot ? 'yes' : 'no'}
); } beforeEach(() => { vi.clearAllMocks(); vi.useRealTimers(); window.localStorage.clear(); storageMocks.getSettings.mockResolvedValue({ musicVolume: 0.42, platformTheme: 'light', }); storageMocks.putSettings.mockResolvedValue({ musicVolume: 0.6, platformTheme: 'light', }); storageMocks.getSaveSnapshot.mockResolvedValue(null); storageMocks.putSaveSnapshot.mockResolvedValue(null); storageMocks.deleteSaveSnapshot.mockResolvedValue({ ok: true, }); }); test('unauthenticated settings use local cache and skip remote runtime settings requests', async () => { window.localStorage.setItem( 'tavernrealms.settings.v1', JSON.stringify({ version: 1, musicVolume: 0.33, platformTheme: 'dark', }), ); render(); expect(screen.getByTestId('music-volume').textContent).toBe('0.33'); expect(storageMocks.getSettings).not.toHaveBeenCalled(); act(() => { screen.getByRole('button', { name: '设置音量' }).click(); }); expect(storageMocks.putSettings).not.toHaveBeenCalled(); expect(readSavedSettings().musicVolume).toBeCloseTo(0.6); }); test('authenticated settings hydrate from remote settings and sync later changes back to the server', async () => { storageMocks.getSettings.mockResolvedValue({ musicVolume: 0.8, platformTheme: 'dark', }); render(); await waitFor(() => { expect(storageMocks.getSettings).toHaveBeenCalledTimes(1); }); expect(screen.getByTestId('music-volume').textContent).toBe('0.80'); vi.useFakeTimers(); act(() => { screen.getByRole('button', { name: '设置音量' }).click(); }); await act(async () => { vi.advanceTimersByTime(200); await Promise.resolve(); }); expect(storageMocks.putSettings).toHaveBeenCalledTimes(1); expect(storageMocks.putSettings).toHaveBeenCalledWith( { musicVolume: 0.6, platformTheme: 'dark' }, expect.objectContaining({ signal: expect.any(AbortSignal), }), ); expect(readSavedSettings().musicVolume).toBeCloseTo(0.6); }); test('unauthenticated runtime skips remote snapshot hydration', async () => { render(); await waitFor(() => { expect(screen.getByTestId('hydrating').textContent).toBe('no'); }); expect(screen.getByTestId('saved-game').textContent).toBe('no'); expect(storageMocks.getSaveSnapshot).not.toHaveBeenCalled(); }); test('runtime autosave requests backend checkpoint without uploading local state', async () => { vi.useFakeTimers(); storageMocks.getSaveSnapshot.mockResolvedValue(null); storageMocks.putSaveSnapshot.mockResolvedValue({ version: 2, savedAt: '2026-04-28T10:00:00.000Z', bottomTab: 'adventure', currentStory: null, gameState: { runtimeSessionId: 'runtime-main', currentScene: 'Story', }, }); const gameState = { runtimeSessionId: 'runtime-main', runtimePersistenceDisabled: false, runtimeMode: 'play', currentScene: 'Story', worldType: 'CUSTOM', playerCharacter: { id: 'hero_001' }, runtimeStats: { playTimeMs: 0, lastPlayTickAt: null, hostileNpcsDefeated: 0, questsAccepted: 0, itemsUsed: 0, scenesTraveled: 0, }, } as unknown as GameState; const story = { text: '开场', options: [], streaming: false } as StoryMoment; render( , ); await act(async () => { await Promise.resolve(); }); expect(storageMocks.getSaveSnapshot).toHaveBeenCalledTimes(1); await act(async () => { vi.advanceTimersByTime(400); await Promise.resolve(); await Promise.resolve(); }); expect(storageMocks.putSaveSnapshot).toHaveBeenCalledWith( { sessionId: 'runtime-main', bottomTab: 'adventure', }, expect.objectContaining({ signal: expect.any(AbortSignal), }), ); });