237 lines
6.3 KiB
TypeScript
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),
|
|
}),
|
|
);
|
|
});
|