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
This commit is contained in:
246
web/src/__tests__/stores/socket.test.ts
Normal file
246
web/src/__tests__/stores/socket.test.ts
Normal file
@@ -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<typeof useSocketStore>
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
256
web/src/__tests__/stores/ui.test.ts
Normal file
256
web/src/__tests__/stores/ui.test.ts
Normal file
@@ -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> = {}): 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<typeof useUiStore>
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
786
web/src/__tests__/stores/world.test.ts
Normal file
786
web/src/__tests__/stores/world.test.ts
Normal file
@@ -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> = {}): 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> = {}): 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<typeof useWorldStore>
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,9 @@ export const useUiStore = defineStore('ui', () => {
|
||||
const isLoadingDetail = ref(false);
|
||||
const detailError = ref<string | null>(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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,11 @@ export const useWorldStore = defineStore('world', () => {
|
||||
const currentPhenomenon = ref<CelestialPhenomenon | null>(null);
|
||||
const phenomenaList = shallowRef<CelestialPhenomenon[]>([]);
|
||||
|
||||
// 请求计数器,用于处理 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 = []; // 清空旧数据,避免筛选切换时显示残留。
|
||||
|
||||
Reference in New Issue
Block a user