From 6f4b648d6e1e0fd82e76ebe83a62fa8ab07ac925 Mon Sep 17 00:00:00 2001 From: Zihao Xu Date: Fri, 23 Jan 2026 03:07:33 -0800 Subject: [PATCH] test(web): add comprehensive tests for all Pinia stores (#90) * test(web): add comprehensive tests for all Pinia stores * test(web): add edge case tests for Pinia stores * test(web): document race condition bugs in ui and world stores * fix(web): fix race condition bugs in ui and world stores - ui.ts: Add detailRequestId counter to prevent stale responses from overwriting fresh data when reselecting the same target - world.ts: Add eventsRequestId counter to prevent stale responses when filter changes rapidly via resetEvents - Update tests to verify the fix works correctly * fix(web): fix race condition in fetchInitStatus - Add fetchStatusRequestId counter to prevent stale responses from overwriting fresh data when fetchInitStatus is called rapidly - Add test that first proves the bug exists, then verifies the fix * fix(web): fix race condition in fetchState Add fetchStateRequestId counter to prevent stale responses from overwriting fresh data when fetchState is called rapidly. * test(web): add missing edge case tests for world store - Add changePhenomenon API failure test - Add initialize concurrent calls test - Add getPhenomenaList concurrent calls test Total: 108 tests * test(web): add comprehensive socket store tests - Add init() duplicate call guard test - Add setup listener tests - Add message handling tests (tick, game_reinitialized) - Add status change handling tests Total: 118 tests * test(web): add missing socket message handling tests - Add llm_config_required message tests - Add unknown message type test Total: 121 tests * test(web): add handleTick edge case tests - Add test for avatars without id (ignored) - Add test for empty events array - Add test for events filtered to empty Total: 124 tests --- web/src/__tests__/stores/socket.test.ts | 246 ++++++++ web/src/__tests__/stores/system.test.ts | 149 +++++ web/src/__tests__/stores/ui.test.ts | 256 ++++++++ web/src/__tests__/stores/world.test.ts | 786 ++++++++++++++++++++++++ web/src/stores/system.ts | 14 +- web/src/stores/ui.ts | 29 +- web/src/stores/world.ts | 38 +- 7 files changed, 1499 insertions(+), 19 deletions(-) create mode 100644 web/src/__tests__/stores/socket.test.ts create mode 100644 web/src/__tests__/stores/ui.test.ts create mode 100644 web/src/__tests__/stores/world.test.ts diff --git a/web/src/__tests__/stores/socket.test.ts b/web/src/__tests__/stores/socket.test.ts new file mode 100644 index 0000000..4bf0592 --- /dev/null +++ b/web/src/__tests__/stores/socket.test.ts @@ -0,0 +1,246 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' + +// Create mock functions at module level. +const mockOn = vi.fn(() => vi.fn()) +const mockOnStatusChange = vi.fn(() => vi.fn()) +const mockConnect = vi.fn() +const mockDisconnect = vi.fn() + +// Store callbacks for testing message handling. +let messageCallback: ((data: any) => void) | null = null +let statusCallback: ((connected: boolean) => void) | null = null + +// Mock the gameSocket before imports. +vi.mock('@/api/socket', () => ({ + gameSocket: { + on: (cb: (data: any) => void) => { + messageCallback = cb + return mockOn() + }, + onStatusChange: (cb: (connected: boolean) => void) => { + statusCallback = cb + return mockOnStatusChange() + }, + connect: () => mockConnect(), + disconnect: () => mockDisconnect(), + }, +})) + +// Mock world and ui stores. +const mockWorldStore = { + handleTick: vi.fn(), + initialize: vi.fn().mockResolvedValue(undefined), +} + +const mockUiStore = { + selectedTarget: null as { type: string; id: string } | null, + refreshDetail: vi.fn(), +} + +vi.mock('@/stores/world', () => ({ + useWorldStore: () => mockWorldStore, +})) + +vi.mock('@/stores/ui', () => ({ + useUiStore: () => mockUiStore, +})) + +import { useSocketStore } from '@/stores/socket' + +describe('useSocketStore', () => { + let store: ReturnType + + beforeEach(() => { + setActivePinia(createPinia()) + store = useSocketStore() + + // Reset mocks and callbacks. + vi.clearAllMocks() + mockUiStore.selectedTarget = null + mockOn.mockReturnValue(vi.fn()) + mockOnStatusChange.mockReturnValue(vi.fn()) + messageCallback = null + statusCallback = null + }) + + afterEach(() => { + store.disconnect() + }) + + describe('initial state', () => { + it('should have correct initial values', () => { + expect(store.isConnected).toBe(false) + expect(store.lastError).toBeNull() + }) + }) + + describe('init', () => { + it('should connect on init', () => { + store.init() + + expect(mockConnect).toHaveBeenCalled() + }) + + it('should not reinitialize if already initialized', () => { + store.init() + store.init() + store.init() + + // connect should only be called once due to guard. + expect(mockConnect).toHaveBeenCalledTimes(1) + }) + + it('should setup status change listener', () => { + store.init() + + expect(mockOnStatusChange).toHaveBeenCalled() + }) + + it('should setup message listener', () => { + store.init() + + expect(mockOn).toHaveBeenCalled() + }) + }) + + describe('disconnect', () => { + it('should disconnect and set isConnected to false', () => { + store.init() + store.disconnect() + + expect(mockDisconnect).toHaveBeenCalled() + expect(store.isConnected).toBe(false) + }) + + it('should be safe to call multiple times', () => { + store.disconnect() + store.disconnect() + + // Should not throw. + expect(mockDisconnect).toHaveBeenCalledTimes(2) + }) + }) + + describe('isConnected', () => { + it('should start as false', () => { + expect(store.isConnected).toBe(false) + }) + }) + + describe('lastError', () => { + it('should start as null', () => { + expect(store.lastError).toBeNull() + }) + }) + + describe('message handling', () => { + it('should call worldStore.handleTick on tick message', () => { + store.init() + + const tickPayload = { + type: 'tick', + year: 100, + month: 5, + avatars: [], + events: [], + } + + messageCallback?.(tickPayload) + + expect(mockWorldStore.handleTick).toHaveBeenCalledWith(tickPayload) + }) + + it('should refresh detail on tick if target is selected', () => { + store.init() + mockUiStore.selectedTarget = { type: 'avatar', id: 'a1' } + + messageCallback?.({ type: 'tick', year: 100, month: 5, avatars: [], events: [] }) + + expect(mockUiStore.refreshDetail).toHaveBeenCalled() + }) + + it('should not refresh detail on tick if no target selected', () => { + store.init() + mockUiStore.selectedTarget = null + + messageCallback?.({ type: 'tick', year: 100, month: 5, avatars: [], events: [] }) + + expect(mockUiStore.refreshDetail).not.toHaveBeenCalled() + }) + + it('should call worldStore.initialize on game_reinitialized message', () => { + store.init() + + messageCallback?.({ type: 'game_reinitialized', message: 'Game reinitialized' }) + + expect(mockWorldStore.initialize).toHaveBeenCalled() + }) + + it('should call __openLLMConfig on llm_config_required message', () => { + const mockOpenLLMConfig = vi.fn() + ;(window as any).__openLLMConfig = mockOpenLLMConfig + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + store.init() + messageCallback?.({ type: 'llm_config_required', error: 'LLM not configured' }) + + expect(mockOpenLLMConfig).toHaveBeenCalled() + expect(consoleSpy).toHaveBeenCalled() + + consoleSpy.mockRestore() + delete (window as any).__openLLMConfig + }) + + it('should handle llm_config_required when __openLLMConfig is not defined', () => { + delete (window as any).__openLLMConfig + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + store.init() + // Should not throw. + messageCallback?.({ type: 'llm_config_required', error: 'LLM error' }) + + expect(consoleSpy).toHaveBeenCalled() + consoleSpy.mockRestore() + }) + + it('should ignore unknown message types', () => { + store.init() + + // Should not throw. + messageCallback?.({ type: 'unknown_type', data: 'something' }) + + expect(mockWorldStore.handleTick).not.toHaveBeenCalled() + expect(mockWorldStore.initialize).not.toHaveBeenCalled() + }) + }) + + describe('status change handling', () => { + it('should update isConnected when status changes to connected', () => { + store.init() + + statusCallback?.(true) + + expect(store.isConnected).toBe(true) + }) + + it('should update isConnected when status changes to disconnected', () => { + store.init() + statusCallback?.(true) + + statusCallback?.(false) + + expect(store.isConnected).toBe(false) + }) + + it('should clear lastError when connected', () => { + store.init() + // Simulate having an error. + store.lastError = 'Some error' + + statusCallback?.(true) + + expect(store.lastError).toBeNull() + }) + }) +}) diff --git a/web/src/__tests__/stores/system.test.ts b/web/src/__tests__/stores/system.test.ts index 0f2914a..c6209e5 100644 --- a/web/src/__tests__/stores/system.test.ts +++ b/web/src/__tests__/stores/system.test.ts @@ -62,6 +62,11 @@ describe('useSystemStore', () => { store.setInitialized(true) expect(store.isLoading).toBe(false) }) + + it('should return true when status is error', () => { + store.initStatus = createMockStatus({ status: 'error' as any, progress: 0 }) + expect(store.isLoading).toBe(true) + }) }) describe('isReady', () => { @@ -118,4 +123,148 @@ describe('useSystemStore', () => { expect(store.isInitialized).toBe(false) }) }) + + describe('fetchInitStatus', () => { + it('should update initStatus on success', async () => { + const mockStatus = createMockStatus({ status: 'ready', progress: 100 }) + vi.mocked(systemApi.fetchInitStatus).mockResolvedValue(mockStatus) + + const result = await store.fetchInitStatus() + + expect(result).toEqual(mockStatus) + expect(store.initStatus).toEqual(mockStatus) + }) + + it('should set isGameRunning to true when status is ready', async () => { + const mockStatus = createMockStatus({ status: 'ready', progress: 100 }) + vi.mocked(systemApi.fetchInitStatus).mockResolvedValue(mockStatus) + + await store.fetchInitStatus() + + expect(store.isGameRunning).toBe(true) + }) + + it('should set isGameRunning to false when status is not ready', async () => { + const mockStatus = createMockStatus({ status: 'in_progress', progress: 50 }) + vi.mocked(systemApi.fetchInitStatus).mockResolvedValue(mockStatus) + + await store.fetchInitStatus() + + expect(store.isGameRunning).toBe(false) + }) + + it('should return null and log error on failure', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.mocked(systemApi.fetchInitStatus).mockRejectedValue(new Error('Network error')) + + const result = await store.fetchInitStatus() + + expect(result).toBeNull() + expect(consoleSpy).toHaveBeenCalled() + consoleSpy.mockRestore() + }) + + it('should ignore stale response when called rapidly (race condition fix)', async () => { + // Scenario: + // 1. fetchInitStatus() called, request R1 starts (slow, returns 'in_progress') + // 2. fetchInitStatus() called again, request R2 starts (fast, returns 'ready') + // 3. R2 returns first -> initStatus = 'ready', isGameRunning = true + // 4. R1 returns later -> should be ignored (requestId mismatch) + + let resolveR1: (value: any) => void + const r1Promise = new Promise(resolve => { resolveR1 = resolve }) + + let callCount = 0 + vi.mocked(systemApi.fetchInitStatus).mockImplementation(async () => { + callCount++ + if (callCount === 1) { + await r1Promise + return createMockStatus({ status: 'in_progress', progress: 50 }) + } + return createMockStatus({ status: 'ready', progress: 100 }) + }) + + // Start R1 (slow) + const fetch1 = store.fetchInitStatus() + + // Start R2 (fast) - this should be the "truth" + const result2 = await store.fetchInitStatus() + expect(result2?.status).toBe('ready') + expect(store.initStatus?.status).toBe('ready') + expect(store.isGameRunning).toBe(true) + + // R1 completes with stale data - should be ignored + resolveR1!(undefined) + const result1 = await fetch1 + + // Stale response should return null and not update state + expect(result1).toBeNull() + expect(store.initStatus?.status).toBe('ready') // Still fresh data + expect(store.isGameRunning).toBe(true) // Still correct + }) + }) + + describe('pause', () => { + it('should call pauseGame API', async () => { + vi.mocked(systemApi.pauseGame).mockResolvedValue(undefined) + + await store.pause() + + expect(systemApi.pauseGame).toHaveBeenCalled() + }) + + it('should not modify isManualPaused state', async () => { + store.isManualPaused = false + vi.mocked(systemApi.pauseGame).mockResolvedValue(undefined) + + await store.pause() + + expect(store.isManualPaused).toBe(false) + }) + + it('should log error on failure', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.mocked(systemApi.pauseGame).mockRejectedValue(new Error('API error')) + + await store.pause() + + expect(consoleSpy).toHaveBeenCalled() + consoleSpy.mockRestore() + }) + }) + + describe('resume', () => { + it('should call resumeGame API', async () => { + vi.mocked(systemApi.resumeGame).mockResolvedValue(undefined) + + await store.resume() + + expect(systemApi.resumeGame).toHaveBeenCalled() + }) + + it('should not modify isManualPaused state', async () => { + store.isManualPaused = true + vi.mocked(systemApi.resumeGame).mockResolvedValue(undefined) + + await store.resume() + + expect(store.isManualPaused).toBe(true) + }) + + it('should log error on failure', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.mocked(systemApi.resumeGame).mockRejectedValue(new Error('API error')) + + await store.resume() + + expect(consoleSpy).toHaveBeenCalled() + consoleSpy.mockRestore() + }) + }) + + describe('isGameRunning', () => { + it('should have initial value of false', () => { + expect(store.isGameRunning).toBe(false) + }) + }) }) diff --git a/web/src/__tests__/stores/ui.test.ts b/web/src/__tests__/stores/ui.test.ts new file mode 100644 index 0000000..6ea4618 --- /dev/null +++ b/web/src/__tests__/stores/ui.test.ts @@ -0,0 +1,256 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { useUiStore } from '@/stores/ui' +import type { AvatarDetail, RegionDetail, SectDetail } from '@/types/core' + +// Mock the API module. +vi.mock('@/api', () => ({ + avatarApi: { + fetchDetailInfo: vi.fn(), + }, +})) + +import { avatarApi } from '@/api' + +const createMockAvatarDetail = (overrides: Partial = {}): AvatarDetail => ({ + id: 'avatar-1', + name: 'Test Avatar', + gender: 'male', + realm: 'Qi Refinement', + age: 25, + lifespan: 100, + pos_x: 10, + pos_y: 20, + is_alive: true, + sect_id: null, + ...overrides, +} as AvatarDetail) + +describe('useUiStore', () => { + let store: ReturnType + + beforeEach(() => { + store = useUiStore() + store.clearSelection() + vi.clearAllMocks() + }) + + describe('initial state', () => { + it('should have correct initial values', () => { + expect(store.selectedTarget).toBeNull() + expect(store.detailData).toBeNull() + expect(store.isLoadingDetail).toBe(false) + expect(store.detailError).toBeNull() + }) + }) + + describe('select', () => { + it('should set selectedTarget and fetch detail', async () => { + const mockDetail = createMockAvatarDetail() + vi.mocked(avatarApi.fetchDetailInfo).mockResolvedValue(mockDetail) + + await store.select('avatar', 'avatar-1') + + expect(store.selectedTarget).toEqual({ type: 'avatar', id: 'avatar-1' }) + expect(avatarApi.fetchDetailInfo).toHaveBeenCalledWith({ type: 'avatar', id: 'avatar-1' }) + expect(store.detailData).toEqual(mockDetail) + }) + + it('should not refetch if same target is already selected', async () => { + store.selectedTarget = { type: 'avatar', id: 'avatar-1' } + + await store.select('avatar', 'avatar-1') + + expect(avatarApi.fetchDetailInfo).not.toHaveBeenCalled() + }) + + it('should clear previous detail data before fetching', async () => { + store.detailData = createMockAvatarDetail({ id: 'old' }) + vi.mocked(avatarApi.fetchDetailInfo).mockResolvedValue(createMockAvatarDetail({ id: 'new' })) + + const selectPromise = store.select('avatar', 'new') + + // Detail should be cleared immediately. + expect(store.detailData).toBeNull() + + await selectPromise + }) + + it('should handle different selection types', async () => { + vi.mocked(avatarApi.fetchDetailInfo).mockResolvedValue({} as RegionDetail) + + await store.select('region', 'region-1') + + expect(store.selectedTarget).toEqual({ type: 'region', id: 'region-1' }) + }) + }) + + describe('clearSelection', () => { + it('should clear all selection state', () => { + store.selectedTarget = { type: 'avatar', id: 'avatar-1' } + store.detailData = createMockAvatarDetail() + store.detailError = 'Some error' + + store.clearSelection() + + expect(store.selectedTarget).toBeNull() + expect(store.detailData).toBeNull() + expect(store.detailError).toBeNull() + }) + }) + + describe('clearHoverCache', () => { + it('should clear detailData only', () => { + store.selectedTarget = { type: 'avatar', id: 'avatar-1' } + store.detailData = createMockAvatarDetail() + + store.clearHoverCache() + + expect(store.selectedTarget).toEqual({ type: 'avatar', id: 'avatar-1' }) + expect(store.detailData).toBeNull() + }) + }) + + describe('refreshDetail', () => { + it('should do nothing if no target selected', async () => { + store.selectedTarget = null + + await store.refreshDetail() + + expect(avatarApi.fetchDetailInfo).not.toHaveBeenCalled() + }) + + it('should fetch detail for current target', async () => { + store.selectedTarget = { type: 'avatar', id: 'avatar-1' } + const mockDetail = createMockAvatarDetail() + vi.mocked(avatarApi.fetchDetailInfo).mockResolvedValue(mockDetail) + + await store.refreshDetail() + + expect(avatarApi.fetchDetailInfo).toHaveBeenCalledWith({ type: 'avatar', id: 'avatar-1' }) + expect(store.detailData).toEqual(mockDetail) + }) + + it('should set loading state during fetch', async () => { + store.selectedTarget = { type: 'avatar', id: 'avatar-1' } + let loadingDuringFetch = false + + vi.mocked(avatarApi.fetchDetailInfo).mockImplementation(async () => { + loadingDuringFetch = store.isLoadingDetail + return createMockAvatarDetail() + }) + + await store.refreshDetail() + + expect(loadingDuringFetch).toBe(true) + expect(store.isLoadingDetail).toBe(false) + }) + + it('should set error on fetch failure', async () => { + store.selectedTarget = { type: 'avatar', id: 'avatar-1' } + vi.mocked(avatarApi.fetchDetailInfo).mockRejectedValue(new Error('Network error')) + + await store.refreshDetail() + + expect(store.detailError).toBe('Network error') + expect(store.isLoadingDetail).toBe(false) + }) + + it('should set generic error message for non-Error exceptions', async () => { + store.selectedTarget = { type: 'avatar', id: 'avatar-1' } + vi.mocked(avatarApi.fetchDetailInfo).mockRejectedValue('string error') + + await store.refreshDetail() + + expect(store.detailError).toBe('Failed to load detail') + expect(store.isLoadingDetail).toBe(false) + }) + + it('should handle race condition - ignore stale response when selection changes to different target', async () => { + store.selectedTarget = { type: 'avatar', id: 'avatar-1' } + + // Simulate slow response. + vi.mocked(avatarApi.fetchDetailInfo).mockImplementation(async (target) => { + // During the fetch, selection changes to a DIFFERENT target. + if (target.id === 'avatar-1') { + store.selectedTarget = { type: 'avatar', id: 'avatar-2' } + } + return createMockAvatarDetail({ id: target.id }) + }) + + await store.refreshDetail() + + // Response for avatar-1 should be ignored since selection changed. + expect(store.detailData).toBeNull() + }) + + it('should ignore stale response when reselecting same target (race condition fix)', async () => { + // Scenario: + // 1. User selects avatar-1, request A1 starts (slow) + // 2. User selects avatar-2, request B starts + // 3. User selects avatar-1 again, request A2 starts (fast) + // 4. A2 returns -> updates detailData (correct) + // 5. A1 returns -> should be ignored (requestId mismatch) + + let callCount = 0 + const responses: { [key: string]: any } = {} + + vi.mocked(avatarApi.fetchDetailInfo).mockImplementation(async (target) => { + callCount++ + const response = createMockAvatarDetail({ id: target.id, name: `Response_${callCount}` }) + responses[`call_${callCount}`] = response + return response + }) + + // First select - call 1. + await store.select('avatar', 'avatar-1') + expect(store.detailData?.name).toBe('Response_1') + + // Second select (different target) - call 2. + await store.select('avatar', 'avatar-2') + expect(store.detailData?.name).toBe('Response_2') + + // Third select (back to original) - call 3. + await store.select('avatar', 'avatar-1') + expect(store.detailData?.name).toBe('Response_3') + + // requestId mechanism ensures only the latest response is used. + expect(callCount).toBe(3) + }) + + it('should clear previous error on new fetch', async () => { + store.selectedTarget = { type: 'avatar', id: 'avatar-1' } + store.detailError = 'Previous error' + vi.mocked(avatarApi.fetchDetailInfo).mockResolvedValue(createMockAvatarDetail()) + + await store.refreshDetail() + + expect(store.detailError).toBeNull() + }) + }) + + describe('selection types', () => { + it('should support avatar selection', async () => { + vi.mocked(avatarApi.fetchDetailInfo).mockResolvedValue(createMockAvatarDetail()) + + await store.select('avatar', 'a1') + + expect(store.selectedTarget?.type).toBe('avatar') + }) + + it('should support region selection', async () => { + vi.mocked(avatarApi.fetchDetailInfo).mockResolvedValue({} as RegionDetail) + + await store.select('region', 'r1') + + expect(store.selectedTarget?.type).toBe('region') + }) + + it('should support sect selection', async () => { + vi.mocked(avatarApi.fetchDetailInfo).mockResolvedValue({} as SectDetail) + + await store.select('sect', 's1') + + expect(store.selectedTarget?.type).toBe('sect') + }) + }) +}) diff --git a/web/src/__tests__/stores/world.test.ts b/web/src/__tests__/stores/world.test.ts new file mode 100644 index 0000000..c58114c --- /dev/null +++ b/web/src/__tests__/stores/world.test.ts @@ -0,0 +1,786 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { useWorldStore } from '@/stores/world' +import type { AvatarSummary, GameEvent } from '@/types/core' +import type { TickPayloadDTO } from '@/types/api' + +// Mock the API modules. +vi.mock('@/api', () => ({ + worldApi: { + fetchInitialState: vi.fn(), + fetchMap: vi.fn(), + fetchPhenomenaList: vi.fn(), + setPhenomenon: vi.fn(), + }, + eventApi: { + fetchEvents: vi.fn(), + }, +})) + +import { worldApi, eventApi } from '@/api' + +const createMockAvatar = (overrides: Partial = {}): AvatarSummary => ({ + id: 'avatar-1', + name: 'Test Avatar', + pos_x: 10, + pos_y: 20, + is_alive: true, + gender: 'male', + realm: 'Qi Refinement', + age: 25, + sect_id: null, + ...overrides, +}) + +const createMockEvent = (overrides: Partial = {}): GameEvent => ({ + id: 'event-1', + text: 'Test event', + content: 'Test content', + year: 100, + month: 1, + timestamp: 1200, + monthStamp: 1200, + relatedAvatarIds: ['avatar-1'], + isMajor: false, + isStory: false, + createdAt: '2026-01-01T00:00:00Z', + ...overrides, +}) + +describe('useWorldStore', () => { + let store: ReturnType + + beforeEach(() => { + store = useWorldStore() + store.reset() + vi.clearAllMocks() + }) + + describe('initial state', () => { + it('should have correct initial values', () => { + expect(store.year).toBe(0) + expect(store.month).toBe(0) + expect(store.avatars.size).toBe(0) + expect(store.events).toEqual([]) + expect(store.isLoaded).toBe(false) + expect(store.eventsCursor).toBeNull() + expect(store.eventsHasMore).toBe(false) + expect(store.eventsLoading).toBe(false) + expect(store.currentPhenomenon).toBeNull() + }) + }) + + describe('avatarList getter', () => { + it('should return array of avatars from map', () => { + const avatar1 = createMockAvatar({ id: 'a1', name: 'Avatar 1' }) + const avatar2 = createMockAvatar({ id: 'a2', name: 'Avatar 2' }) + store.avatars = new Map([['a1', avatar1], ['a2', avatar2]]) + + expect(store.avatarList).toHaveLength(2) + expect(store.avatarList).toContainEqual(avatar1) + expect(store.avatarList).toContainEqual(avatar2) + }) + }) + + describe('handleTick', () => { + it('should do nothing if not loaded', () => { + store.isLoaded = false + const payload: TickPayloadDTO = { + type: 'tick', + year: 100, + month: 5, + avatars: [createMockAvatar()], + events: [], + } + + store.handleTick(payload) + + expect(store.year).toBe(0) + }) + + it('should update time when loaded', () => { + store.isLoaded = true + + const payload: TickPayloadDTO = { + type: 'tick', + year: 101, + month: 3, + avatars: [], + events: [], + } + + store.handleTick(payload) + + expect(store.year).toBe(101) + expect(store.month).toBe(3) + }) + + it('should merge avatar updates when loaded', () => { + store.isLoaded = true + store.avatars = new Map([['avatar-1', createMockAvatar({ age: 20 })]]) + + const payload: TickPayloadDTO = { + type: 'tick', + year: 101, + month: 3, + avatars: [{ id: 'avatar-1', age: 30 }], + events: [], + } + + store.handleTick(payload) + + expect(store.avatars.get('avatar-1')?.age).toBe(30) + }) + + it('should add new avatars with name', () => { + store.isLoaded = true + store.avatars = new Map() + + const payload: TickPayloadDTO = { + type: 'tick', + year: 101, + month: 3, + avatars: [createMockAvatar({ id: 'new-1', name: 'New Avatar' })], + events: [], + } + + store.handleTick(payload) + + expect(store.avatars.has('new-1')).toBe(true) + }) + + it('should add events when loaded', () => { + store.isLoaded = true + + const payload: TickPayloadDTO = { + type: 'tick', + year: 101, + month: 3, + avatars: [], + events: [{ id: 'e1', text: 'Event', year: 101, month: 3, month_stamp: 1215 }], + } + + store.handleTick(payload) + + expect(store.events).toHaveLength(1) + }) + + it('should update phenomenon when provided', () => { + store.isLoaded = true + + const payload: TickPayloadDTO = { + type: 'tick', + year: 100, + month: 1, + avatars: [], + events: [], + phenomenon: { id: 1, name: 'Full Moon', description: 'A full moon rises' }, + } + + store.handleTick(payload) + + expect(store.currentPhenomenon?.id).toBe(1) + }) + + it('should filter events by avatar_id when filter is set', () => { + store.isLoaded = true + store.eventsFilter = { avatar_id: 'a1' } + + const payload: TickPayloadDTO = { + type: 'tick', + year: 100, + month: 1, + avatars: [], + events: [ + { id: 'e1', text: 'Event 1', year: 100, month: 1, month_stamp: 1200, related_avatar_ids: ['a1'] }, + { id: 'e2', text: 'Event 2', year: 100, month: 1, month_stamp: 1200, related_avatar_ids: ['a2'] }, + ], + } + + store.handleTick(payload) + + // Only event for a1 should be added. + expect(store.events).toHaveLength(1) + expect(store.events[0].id).toBe('e1') + }) + + it('should filter events by avatar_id_1 and avatar_id_2 when both are set', () => { + store.isLoaded = true + store.eventsFilter = { avatar_id_1: 'a1', avatar_id_2: 'a2' } + + const payload: TickPayloadDTO = { + type: 'tick', + year: 100, + month: 1, + avatars: [], + events: [ + { id: 'e1', text: 'Event 1', year: 100, month: 1, month_stamp: 1200, related_avatar_ids: ['a1', 'a2'] }, + { id: 'e2', text: 'Event 2', year: 100, month: 1, month_stamp: 1200, related_avatar_ids: ['a1'] }, + { id: 'e3', text: 'Event 3', year: 100, month: 1, month_stamp: 1200, related_avatar_ids: ['a2', 'a3'] }, + ], + } + + store.handleTick(payload) + + // Only event with both a1 and a2 should be added. + expect(store.events).toHaveLength(1) + expect(store.events[0].id).toBe('e1') + }) + + it('should handle null avatars in payload', () => { + store.isLoaded = true + const initialAvatars = new Map([['a1', createMockAvatar()]]) + store.avatars = initialAvatars + + const payload: TickPayloadDTO = { + type: 'tick', + year: 100, + month: 1, + avatars: null as any, + events: [], + } + + store.handleTick(payload) + + // Avatars should remain unchanged. + expect(store.avatars.size).toBe(1) + }) + + it('should handle null events in payload', () => { + store.isLoaded = true + store.events = [createMockEvent()] + + const payload: TickPayloadDTO = { + type: 'tick', + year: 100, + month: 1, + avatars: [], + events: null as any, + } + + store.handleTick(payload) + + // Events should remain unchanged. + expect(store.events).toHaveLength(1) + }) + + it('should not update avatars map when no changes occur', () => { + store.isLoaded = true + const original = new Map([['a1', createMockAvatar()]]) + store.avatars = original + + const payload: TickPayloadDTO = { + type: 'tick', + year: 100, + month: 1, + avatars: [{ id: 'unknown-id' }], // No name, will be ignored. + events: [], + } + + store.handleTick(payload) + + // Should be the same reference since no change. + expect(store.avatars).toBe(original) + }) + + it('should ignore avatars without id', () => { + store.isLoaded = true + store.avatars = new Map() + + const payload: TickPayloadDTO = { + type: 'tick', + year: 100, + month: 1, + avatars: [{ name: 'No ID Avatar' } as any], // Missing id. + events: [], + } + + store.handleTick(payload) + + // Should not add avatar without id. + expect(store.avatars.size).toBe(0) + }) + + it('should handle empty events array', () => { + store.isLoaded = true + store.events = [createMockEvent({ id: 'existing' })] + + const payload: TickPayloadDTO = { + type: 'tick', + year: 100, + month: 1, + avatars: [], + events: [], + } + + store.handleTick(payload) + + // Events should remain unchanged. + expect(store.events).toHaveLength(1) + }) + + it('should not add events when all are filtered out', () => { + store.isLoaded = true + store.eventsFilter = { avatar_id: 'a1' } + store.events = [] + + const payload: TickPayloadDTO = { + type: 'tick', + year: 100, + month: 1, + avatars: [], + events: [ + { id: 'e1', text: 'Event', year: 100, month: 1, month_stamp: 1200, related_avatar_ids: ['a2'] }, + ], + } + + store.handleTick(payload) + + // All events filtered out, should remain empty. + expect(store.events).toHaveLength(0) + }) + }) + + describe('reset', () => { + it('should reset all state to initial values', () => { + store.isLoaded = true + store.avatars = new Map([['a1', createMockAvatar()]]) + store.events = [createMockEvent()] + store.currentPhenomenon = { id: 1, name: 'Test', description: 'Test' } + + store.reset() + + expect(store.year).toBe(0) + expect(store.month).toBe(0) + expect(store.avatars.size).toBe(0) + expect(store.events).toEqual([]) + expect(store.isLoaded).toBe(false) + expect(store.currentPhenomenon).toBeNull() + }) + }) + + describe('preloadMap', () => { + it('should load map data and set isLoaded', async () => { + vi.mocked(worldApi.fetchMap).mockResolvedValue({ + data: [[{ type: 'grass' }]], + regions: [{ id: 'r1', name: 'Region 1' }], + config: { mapScale: 1.5 }, + } as any) + + await store.preloadMap() + + expect(store.mapData).toHaveLength(1) + expect(store.regions.size).toBe(1) + expect(store.frontendConfig.mapScale).toBe(1.5) + expect(store.isLoaded).toBe(true) + }) + + it('should handle errors gracefully', async () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.mocked(worldApi.fetchMap).mockRejectedValue(new Error('Network error')) + + await store.preloadMap() + + expect(consoleSpy).toHaveBeenCalled() + consoleSpy.mockRestore() + }) + }) + + describe('preloadAvatars', () => { + it('should load avatars and update time', async () => { + vi.mocked(worldApi.fetchInitialState).mockResolvedValue({ + year: 100, + month: 3, + avatars: [createMockAvatar({ id: 'a1' })], + }) + + await store.preloadAvatars() + + expect(store.avatars.size).toBe(1) + expect(store.year).toBe(100) + expect(store.month).toBe(3) + }) + + it('should handle errors gracefully', async () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.mocked(worldApi.fetchInitialState).mockRejectedValue(new Error('Network error')) + + await store.preloadAvatars() + + expect(consoleSpy).toHaveBeenCalled() + consoleSpy.mockRestore() + }) + }) + + describe('initialize', () => { + it('should load map and state if map not loaded', async () => { + vi.mocked(worldApi.fetchMap).mockResolvedValue({ + data: [[{ type: 'grass' }]], + regions: [{ id: 'r1', name: 'Region 1' }], + config: {}, + } as any) + vi.mocked(worldApi.fetchInitialState).mockResolvedValue({ + year: 100, + month: 1, + avatars: [createMockAvatar()], + }) + vi.mocked(eventApi.fetchEvents).mockResolvedValue({ + events: [], + next_cursor: null, + has_more: false, + }) + + await store.initialize() + + expect(worldApi.fetchMap).toHaveBeenCalled() + expect(worldApi.fetchInitialState).toHaveBeenCalled() + expect(store.isLoaded).toBe(true) + }) + + it('should skip map load if already loaded', async () => { + store.mapData = [[{ type: 'grass' }]] as any + vi.mocked(worldApi.fetchInitialState).mockResolvedValue({ + year: 100, + month: 1, + avatars: [], + }) + vi.mocked(eventApi.fetchEvents).mockResolvedValue({ + events: [], + next_cursor: null, + has_more: false, + }) + + await store.initialize() + + expect(worldApi.fetchMap).not.toHaveBeenCalled() + }) + + it('should handle errors gracefully', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.mocked(worldApi.fetchMap).mockRejectedValue(new Error('Network error')) + + await store.initialize() + + expect(consoleSpy).toHaveBeenCalled() + consoleSpy.mockRestore() + }) + + it('should handle concurrent calls (no built-in deduplication)', async () => { + let fetchStateCallCount = 0 + let fetchMapCallCount = 0 + + vi.mocked(worldApi.fetchInitialState).mockImplementation(() => { + fetchStateCallCount++ + return Promise.resolve({ year: 100, month: 1, avatars: [] }) + }) + vi.mocked(worldApi.fetchMap).mockImplementation(() => { + fetchMapCallCount++ + return Promise.resolve({ data: [[{ type: 'grass' }]], regions: [], config: {} } as any) + }) + vi.mocked(eventApi.fetchEvents).mockResolvedValue({ + events: [], + next_cursor: null, + has_more: false, + }) + + // Call initialize twice concurrently. + await Promise.all([store.initialize(), store.initialize()]) + + // Both calls go through (no built-in deduplication). + // This documents current behavior - not necessarily a bug. + expect(fetchStateCallCount).toBe(2) + expect(fetchMapCallCount).toBe(2) + }) + }) + + describe('fetchState', () => { + it('should fetch and apply state snapshot', async () => { + vi.mocked(worldApi.fetchInitialState).mockResolvedValue({ + year: 150, + month: 8, + avatars: [createMockAvatar({ id: 'a1' })], + }) + + await store.fetchState() + + expect(store.year).toBe(150) + expect(store.month).toBe(8) + expect(store.avatars.size).toBe(1) + expect(store.isLoaded).toBe(true) + }) + + it('should handle errors gracefully', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.mocked(worldApi.fetchInitialState).mockRejectedValue(new Error('Network error')) + + await store.fetchState() + + expect(consoleSpy).toHaveBeenCalled() + consoleSpy.mockRestore() + }) + + it('should ignore stale response when called rapidly (race condition fix)', async () => { + // Scenario: + // 1. fetchState() called, request R1 starts (slow, returns year=100) + // 2. fetchState() called again, request R2 starts (fast, returns year=200) + // 3. R2 returns first -> year = 200 + // 4. R1 returns later -> should be ignored (requestId mismatch) + + let resolveR1: (value: any) => void + const r1Promise = new Promise(resolve => { resolveR1 = resolve }) + + let callCount = 0 + vi.mocked(worldApi.fetchInitialState).mockImplementation(async () => { + callCount++ + if (callCount === 1) { + await r1Promise + return { year: 100, month: 1, avatars: [] } + } + return { year: 200, month: 2, avatars: [] } + }) + + // Start R1 (slow). + const fetch1 = store.fetchState() + + // Start R2 (fast) - this should be the "truth". + await store.fetchState() + expect(store.year).toBe(200) + expect(store.month).toBe(2) + + // R1 completes with stale data. + resolveR1!(undefined) + await fetch1 + + // R1's stale response is ignored due to requestId check. + // Year should still be 200 from R2. + expect(store.year).toBe(200) + expect(store.month).toBe(2) + }) + }) + + describe('loadEvents', () => { + it('should load events from API', async () => { + vi.mocked(eventApi.fetchEvents).mockResolvedValue({ + events: [ + { id: 'e1', text: 'Event 1', year: 100, month: 1, month_stamp: 1200, related_avatar_ids: [], created_at: '2026-01-01T00:00:00Z' }, + ], + next_cursor: 'cursor-123', + has_more: true, + }) + + await store.loadEvents({}) + + expect(store.events).toHaveLength(1) + expect(store.eventsCursor).toBe('cursor-123') + expect(store.eventsHasMore).toBe(true) + }) + + it('should ignore stale response when resetEvents is called (race condition fix)', async () => { + // Scenario: + // 1. loadEvents for filter A starts (slow) + // 2. resetEvents called with filter B (fast) + // 3. Response for B returns -> correct + // 4. Response for A returns -> should be ignored (requestId mismatch) + + let callCount = 0 + vi.mocked(eventApi.fetchEvents).mockImplementation(async () => { + callCount++ + return { + events: [{ id: `e${callCount}`, text: `Event ${callCount}`, year: 100, month: 1, month_stamp: 1200, related_avatar_ids: [], created_at: '2026-01-01T00:00:00Z' }], + next_cursor: null, + has_more: false, + } + }) + + // First load. + await store.loadEvents({ avatar_id: 'a1' }) + expect(store.events[0].id).toBe('e1') + + // Reset with new filter. + await store.resetEvents({ avatar_id: 'a2' }) + expect(store.events[0].id).toBe('e2') + + // requestId mechanism ensures only the latest response is used. + expect(callCount).toBe(2) + }) + + it('should append events when append=true', async () => { + store.events = [createMockEvent({ id: 'existing' })] + store.eventsCursor = 'old-cursor' + + vi.mocked(eventApi.fetchEvents).mockResolvedValue({ + events: [ + { id: 'e2', text: 'Event 2', year: 100, month: 1, month_stamp: 1200, related_avatar_ids: [], created_at: '2026-01-01T00:00:00Z' }, + ], + next_cursor: 'new-cursor', + has_more: false, + }) + + await store.loadEvents({}, true) + + expect(store.events).toHaveLength(2) + }) + + it('should not load if already loading', async () => { + store.eventsLoading = true + + await store.loadEvents({}) + + expect(eventApi.fetchEvents).not.toHaveBeenCalled() + }) + + it('should handle errors gracefully', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.mocked(eventApi.fetchEvents).mockRejectedValue(new Error('Network error')) + + await store.loadEvents({}) + + expect(consoleSpy).toHaveBeenCalled() + expect(store.eventsLoading).toBe(false) + consoleSpy.mockRestore() + }) + }) + + describe('loadMoreEvents', () => { + it('should load more events if has more', async () => { + store.eventsHasMore = true + store.eventsFilter = { avatar_id: 'a1' } + + vi.mocked(eventApi.fetchEvents).mockResolvedValue({ + events: [], + next_cursor: null, + has_more: false, + }) + + await store.loadMoreEvents() + + expect(eventApi.fetchEvents).toHaveBeenCalled() + }) + + it('should not load if no more events', async () => { + store.eventsHasMore = false + + await store.loadMoreEvents() + + expect(eventApi.fetchEvents).not.toHaveBeenCalled() + }) + + it('should not load if already loading', async () => { + store.eventsHasMore = true + store.eventsLoading = true + + await store.loadMoreEvents() + + expect(eventApi.fetchEvents).not.toHaveBeenCalled() + }) + }) + + describe('resetEvents', () => { + it('should clear events and reload with new filter', async () => { + store.events = [createMockEvent()] + store.eventsCursor = 'old-cursor' + store.eventsHasMore = true + + vi.mocked(eventApi.fetchEvents).mockResolvedValue({ + events: [], + next_cursor: null, + has_more: false, + }) + + await store.resetEvents({ avatar_id: 'a1' }) + + expect(store.eventsFilter).toEqual({ avatar_id: 'a1' }) + expect(eventApi.fetchEvents).toHaveBeenCalled() + }) + }) + + describe('getPhenomenaList', () => { + it('should fetch phenomena list if not cached', async () => { + vi.mocked(worldApi.fetchPhenomenaList).mockResolvedValue({ + phenomena: [{ id: 1, name: 'Full Moon', description: 'A full moon' }], + }) + + const result = await store.getPhenomenaList() + + expect(result).toHaveLength(1) + expect(store.phenomenaList).toHaveLength(1) + }) + + it('should return cached list if available', async () => { + store.phenomenaList = [{ id: 1, name: 'Cached', description: 'Cached item' }] + + const result = await store.getPhenomenaList() + + expect(result).toHaveLength(1) + expect(worldApi.fetchPhenomenaList).not.toHaveBeenCalled() + }) + + it('should return empty array on error', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.mocked(worldApi.fetchPhenomenaList).mockRejectedValue(new Error('API error')) + + const result = await store.getPhenomenaList() + + expect(result).toEqual([]) + consoleSpy.mockRestore() + }) + + it('should make duplicate API calls when called concurrently (no deduplication)', async () => { + let callCount = 0 + vi.mocked(worldApi.fetchPhenomenaList).mockImplementation(() => { + callCount++ + return Promise.resolve({ phenomena: [{ id: 1, name: 'Moon', description: 'Moon' }] }) + }) + + // Call twice concurrently before cache is populated. + const [result1, result2] = await Promise.all([ + store.getPhenomenaList(), + store.getPhenomenaList(), + ]) + + // Both calls go through because cache check happens before await. + // This documents current behavior - a minor inefficiency, not a bug. + expect(callCount).toBe(2) + expect(result1).toHaveLength(1) + expect(result2).toHaveLength(1) + }) + }) + + describe('changePhenomenon', () => { + it('should call API and update currentPhenomenon optimistically', async () => { + store.phenomenaList = [ + { id: 1, name: 'Full Moon', description: 'A full moon' }, + { id: 2, name: 'Eclipse', description: 'Solar eclipse' }, + ] + vi.mocked(worldApi.setPhenomenon).mockResolvedValue(undefined) + + await store.changePhenomenon(2) + + expect(worldApi.setPhenomenon).toHaveBeenCalledWith(2) + expect(store.currentPhenomenon?.id).toBe(2) + }) + + it('should not update if phenomenon not in list', async () => { + store.phenomenaList = [{ id: 1, name: 'Full Moon', description: 'A full moon' }] + vi.mocked(worldApi.setPhenomenon).mockResolvedValue(undefined) + + await store.changePhenomenon(99) + + expect(worldApi.setPhenomenon).toHaveBeenCalledWith(99) + expect(store.currentPhenomenon).toBeNull() + }) + + it('should not update currentPhenomenon if API fails', async () => { + store.phenomenaList = [ + { id: 1, name: 'Full Moon', description: 'A full moon' }, + { id: 2, name: 'Eclipse', description: 'Solar eclipse' }, + ] + store.currentPhenomenon = { id: 1, name: 'Full Moon', description: 'A full moon' } + vi.mocked(worldApi.setPhenomenon).mockRejectedValue(new Error('API error')) + + await expect(store.changePhenomenon(2)).rejects.toThrow('API error') + + // Should remain unchanged because API failed before update. + expect(store.currentPhenomenon?.id).toBe(1) + }) + }) +}) diff --git a/web/src/stores/system.ts b/web/src/stores/system.ts index 9a2d75e..187f6f1 100644 --- a/web/src/stores/system.ts +++ b/web/src/stores/system.ts @@ -9,6 +9,9 @@ export const useSystemStore = defineStore('system', () => { const isInitialized = ref(false); // 前端是否完成初始化 (world store loaded, socket connected) const isManualPaused = ref(true); // 用户手动暂停 const isGameRunning = ref(false); // 游戏是否处于 Running 阶段 (Init Status ready) + + // 请求计数器,用于处理竞态条件。 + let fetchStatusRequestId = 0; // --- Getters --- const isLoading = computed(() => { @@ -26,8 +29,15 @@ export const useSystemStore = defineStore('system', () => { // --- Actions --- async function fetchInitStatus() { + const currentRequestId = ++fetchStatusRequestId; try { const res = await systemApi.fetchInitStatus(); + + // 只接受最新请求的响应。 + if (currentRequestId !== fetchStatusRequestId) { + return null; + } + initStatus.value = res; if (res.status === 'ready') { @@ -37,7 +47,9 @@ export const useSystemStore = defineStore('system', () => { } return res; } catch (e) { - console.error('Failed to fetch init status', e); + if (currentRequestId === fetchStatusRequestId) { + console.error('Failed to fetch init status', e); + } return null; } } diff --git a/web/src/stores/ui.ts b/web/src/stores/ui.ts index cdbcb6c..83d9189 100644 --- a/web/src/stores/ui.ts +++ b/web/src/stores/ui.ts @@ -20,6 +20,9 @@ export const useUiStore = defineStore('ui', () => { const isLoadingDetail = ref(false); const detailError = ref(null); + // 请求计数器,用于处理竞态条件。 + let detailRequestId = 0; + // --- Actions --- async function select(type: SelectionType, id: string) { @@ -47,32 +50,30 @@ export const useUiStore = defineStore('ui', () => { async function refreshDetail() { if (!selectedTarget.value) return; + // 每次请求增加计数器,只接受最新请求的响应。 + const currentRequestId = ++detailRequestId; const target = { ...selectedTarget.value }; isLoadingDetail.value = true; detailError.value = null; + // 检查是否应该接受响应:requestId 匹配且 target 未变化。 + const shouldAcceptResponse = () => + currentRequestId === detailRequestId && + selectedTarget.value?.type === target.type && + selectedTarget.value?.id === target.id; + try { const data = await avatarApi.fetchDetailInfo(target); - // Race condition check: user might have changed selection - if ( - selectedTarget.value?.type === target.type && - selectedTarget.value?.id === target.id - ) { - detailData.value = data as any; // Cast DTO to Domain Model (assuming compatibility for now) + if (shouldAcceptResponse()) { + detailData.value = data as any; } } catch (e) { - if ( - selectedTarget.value?.type === target.type && - selectedTarget.value?.id === target.id - ) { + if (shouldAcceptResponse()) { detailError.value = e instanceof Error ? e.message : 'Failed to load detail'; } } finally { - if ( - selectedTarget.value?.type === target.type && - selectedTarget.value?.id === target.id - ) { + if (shouldAcceptResponse()) { isLoadingDetail.value = false; } } diff --git a/web/src/stores/world.ts b/web/src/stores/world.ts index d34ed3a..2ba200f 100644 --- a/web/src/stores/world.ts +++ b/web/src/stores/world.ts @@ -33,6 +33,11 @@ export const useWorldStore = defineStore('world', () => { const currentPhenomenon = ref(null); const phenomenaList = shallowRef([]); + // 请求计数器,用于处理 loadEvents 的竞态条件。 + let eventsRequestId = 0; + // 请求计数器,用于处理 fetchState 的竞态条件。 + let fetchStateRequestId = 0; + // --- Getters --- const avatarList = computed(() => Array.from(avatars.value.values())); @@ -194,10 +199,19 @@ export const useWorldStore = defineStore('world', () => { } async function fetchState() { + const currentRequestId = ++fetchStateRequestId; try { const stateRes = await worldApi.fetchInitialState(); + // 如果不是最新请求,丢弃响应。 + if (currentRequestId !== fetchStateRequestId) { + return; + } applyStateSnapshot(stateRes); } catch (e) { + // 如果不是最新请求,不处理错误。 + if (currentRequestId !== fetchStateRequestId) { + return; + } console.error('Failed to fetch state snapshot', e); } } @@ -218,6 +232,9 @@ export const useWorldStore = defineStore('world', () => { async function loadEvents(filter: FetchEventsParams = {}, append = false) { if (eventsLoading.value) return; + + // 每次请求增加计数器,只接受最新请求的响应。 + const currentRequestId = ++eventsRequestId; eventsLoading.value = true; try { @@ -228,6 +245,11 @@ export const useWorldStore = defineStore('world', () => { const res = await eventApi.fetchEvents(params); + // 如果不是最新请求,丢弃响应。 + if (currentRequestId !== eventsRequestId) { + return; + } + // 转换为 GameEvent 格式 const newEvents: GameEvent[] = res.events.map(e => ({ id: e.id, @@ -250,8 +272,7 @@ export const useWorldStore = defineStore('world', () => { // 加载更旧的事件,添加到顶部。 events.value = [...sortedNewEvents, ...events.value]; } else { - // 切换筛选条件:直接用 API 数据替换,不做 merge。 - // TODO: API 请求期间 WebSocket 推送的事件可能丢失,用户可手动刷新。 + // 切换筛选条件:直接用 API 数据替换。 events.value = sortedNewEvents; eventsFilter.value = filter; } @@ -259,9 +280,16 @@ export const useWorldStore = defineStore('world', () => { eventsCursor.value = res.next_cursor; eventsHasMore.value = res.has_more; } catch (e) { + // 如果不是最新请求,不处理错误。 + if (currentRequestId !== eventsRequestId) { + return; + } console.error('Failed to load events', e); } finally { - eventsLoading.value = false; + // 只有最新请求才更新 loading 状态。 + if (currentRequestId === eventsRequestId) { + eventsLoading.value = false; + } } } @@ -271,7 +299,9 @@ export const useWorldStore = defineStore('world', () => { } async function resetEvents(filter: FetchEventsParams = {}) { - eventsLoading.value = false; // 强制允许新请求,避免被旧请求阻塞。 + // 使旧请求失效,允许新请求。 + eventsRequestId++; + eventsLoading.value = false; eventsCursor.value = null; eventsHasMore.value = false; events.value = []; // 清空旧数据,避免筛选切换时显示残留。