Files
Genarrative/src/hooks/runtimeAuthGuards.test.tsx

237 lines
6.3 KiB
TypeScript

/* @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'>) =>
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 (
<div>
<div data-testid="music-volume">{settings.musicVolume.toFixed(2)}</div>
<button
type="button"
onClick={() => {
settings.setMusicVolume(0.6);
}}
>
</button>
</div>
);
}
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 (
<div>
<div data-testid="saved-game">
{persistence.hasSavedGame ? 'yes' : 'no'}
</div>
<div data-testid="hydrating">
{persistence.isHydratingSnapshot ? 'yes' : 'no'}
</div>
</div>
);
}
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(<SettingsHarness authenticatedUserId={null} />);
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(<SettingsHarness authenticatedUserId="user-1" />);
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(<PersistenceHarness authenticatedUserId={null} />);
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(
<PersistenceHarness
authenticatedUserId="user-1"
gameState={gameState}
currentStory={story}
/>,
);
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),
}),
);
});