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:
Zihao Xu
2026-01-23 03:07:33 -08:00
committed by GitHub
parent 00c8860c56
commit 6f4b648d6e
7 changed files with 1499 additions and 19 deletions

View 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()
})
})
})

View File

@@ -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)
})
})
})

View 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')
})
})
})

View 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)
})
})
})

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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 = []; // 清空旧数据,避免筛选切换时显示残留。