test(web): add tests for all composables (#101)
Add comprehensive tests for Vue composables: - useSharedTicker (100% coverage) - useTextures (97% coverage) - useGameControl (92% coverage) - useGameInit (69% coverage) Tests cover: - Lifecycle hooks (mount/unmount) - State management - Function exports - Error handling - LLM configuration flow - Keyboard event handling Closes #86
This commit is contained in:
299
web/src/__tests__/composables/useGameControl.test.ts
Normal file
299
web/src/__tests__/composables/useGameControl.test.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { defineComponent, ref, nextTick } from 'vue'
|
||||
import { useUiStore } from '@/stores/ui'
|
||||
import { useSystemStore } from '@/stores/system'
|
||||
|
||||
// Use vi.hoisted to define mocks before vi.mock is hoisted.
|
||||
const { mockFetchStatus, mockFetchConfig, mockTestConnection, mockMessageSuccess, mockMessageWarning, mockMessageError } = vi.hoisted(() => ({
|
||||
mockFetchStatus: vi.fn(),
|
||||
mockFetchConfig: vi.fn(),
|
||||
mockTestConnection: vi.fn(),
|
||||
mockMessageSuccess: vi.fn(),
|
||||
mockMessageWarning: vi.fn(),
|
||||
mockMessageError: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock the API module.
|
||||
vi.mock('@/api', () => ({
|
||||
llmApi: {
|
||||
fetchStatus: mockFetchStatus,
|
||||
fetchConfig: mockFetchConfig,
|
||||
testConnection: mockTestConnection,
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock the discreteApi.
|
||||
vi.mock('@/utils/discreteApi', () => ({
|
||||
message: {
|
||||
success: mockMessageSuccess,
|
||||
warning: mockMessageWarning,
|
||||
error: mockMessageError,
|
||||
},
|
||||
}))
|
||||
|
||||
import { useGameControl } from '@/composables/useGameControl'
|
||||
|
||||
// Helper to create test component.
|
||||
const createTestComponent = (gameInitialized = ref(false)) => {
|
||||
return defineComponent({
|
||||
setup() {
|
||||
const result = useGameControl(gameInitialized)
|
||||
return { ...result }
|
||||
},
|
||||
template: '<div></div>'
|
||||
})
|
||||
}
|
||||
|
||||
describe('useGameControl', () => {
|
||||
let uiStore: ReturnType<typeof useUiStore>
|
||||
let systemStore: ReturnType<typeof useSystemStore>
|
||||
|
||||
beforeEach(() => {
|
||||
uiStore = useUiStore()
|
||||
systemStore = useSystemStore()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up global function.
|
||||
delete (window as any).__openLLMConfig
|
||||
})
|
||||
|
||||
describe('initial state', () => {
|
||||
it('should have correct initial values', () => {
|
||||
const TestComponent = createTestComponent()
|
||||
const wrapper = mount(TestComponent)
|
||||
|
||||
expect(wrapper.vm.showMenu).toBe(false)
|
||||
expect(wrapper.vm.menuDefaultTab).toBe('load')
|
||||
expect(wrapper.vm.canCloseMenu).toBe(true)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('should expose openLLMConfig to window on mount', async () => {
|
||||
const TestComponent = createTestComponent()
|
||||
const wrapper = mount(TestComponent)
|
||||
await nextTick()
|
||||
|
||||
expect((window as any).__openLLMConfig).toBeDefined()
|
||||
expect(typeof (window as any).__openLLMConfig).toBe('function')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleKeydown', () => {
|
||||
it('should clear selection when Escape pressed and target selected', () => {
|
||||
const TestComponent = createTestComponent(ref(true))
|
||||
const wrapper = mount(TestComponent)
|
||||
|
||||
uiStore.selectedTarget = { type: 'avatar', id: '123' }
|
||||
|
||||
wrapper.vm.handleKeydown(new KeyboardEvent('keydown', { key: 'Escape' }))
|
||||
|
||||
expect(uiStore.selectedTarget).toBeNull()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('should open menu when Escape pressed and no selection', () => {
|
||||
const TestComponent = createTestComponent(ref(true))
|
||||
const wrapper = mount(TestComponent)
|
||||
|
||||
uiStore.selectedTarget = null
|
||||
wrapper.vm.showMenu = false
|
||||
|
||||
wrapper.vm.handleKeydown(new KeyboardEvent('keydown', { key: 'Escape' }))
|
||||
|
||||
expect(wrapper.vm.showMenu).toBe(true)
|
||||
expect(wrapper.vm.menuDefaultTab).toBe('load')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('should close menu when Escape pressed and menu open and closable', () => {
|
||||
const TestComponent = createTestComponent(ref(true))
|
||||
const wrapper = mount(TestComponent)
|
||||
|
||||
uiStore.selectedTarget = null
|
||||
wrapper.vm.showMenu = true
|
||||
wrapper.vm.canCloseMenu = true
|
||||
|
||||
wrapper.vm.handleKeydown(new KeyboardEvent('keydown', { key: 'Escape' }))
|
||||
|
||||
expect(wrapper.vm.showMenu).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('should not close menu when Escape pressed but menu not closable', () => {
|
||||
const TestComponent = createTestComponent(ref(true))
|
||||
const wrapper = mount(TestComponent)
|
||||
|
||||
uiStore.selectedTarget = null
|
||||
wrapper.vm.showMenu = true
|
||||
wrapper.vm.canCloseMenu = false
|
||||
|
||||
wrapper.vm.handleKeydown(new KeyboardEvent('keydown', { key: 'Escape' }))
|
||||
|
||||
expect(wrapper.vm.showMenu).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('should ignore non-Escape keys', () => {
|
||||
const TestComponent = createTestComponent(ref(true))
|
||||
const wrapper = mount(TestComponent)
|
||||
|
||||
wrapper.vm.showMenu = false
|
||||
|
||||
wrapper.vm.handleKeydown(new KeyboardEvent('keydown', { key: 'Enter' }))
|
||||
|
||||
expect(wrapper.vm.showMenu).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('performStartupCheck', () => {
|
||||
it('should open menu with start tab when LLM configured and connected', async () => {
|
||||
mockFetchStatus.mockResolvedValue({ configured: true })
|
||||
mockFetchConfig.mockResolvedValue({ provider: 'openai', model: 'gpt-4' })
|
||||
mockTestConnection.mockResolvedValue(undefined)
|
||||
|
||||
const TestComponent = createTestComponent()
|
||||
const wrapper = mount(TestComponent)
|
||||
|
||||
await wrapper.vm.performStartupCheck()
|
||||
|
||||
expect(wrapper.vm.showMenu).toBe(true)
|
||||
expect(wrapper.vm.menuDefaultTab).toBe('start')
|
||||
expect(wrapper.vm.canCloseMenu).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('should force LLM config when not configured', async () => {
|
||||
mockFetchStatus.mockResolvedValue({ configured: false })
|
||||
|
||||
const TestComponent = createTestComponent()
|
||||
const wrapper = mount(TestComponent)
|
||||
|
||||
await wrapper.vm.performStartupCheck()
|
||||
|
||||
expect(wrapper.vm.showMenu).toBe(true)
|
||||
expect(wrapper.vm.menuDefaultTab).toBe('llm')
|
||||
expect(wrapper.vm.canCloseMenu).toBe(false)
|
||||
expect(mockMessageWarning).toHaveBeenCalledWith('检测到 LLM 未配置,请先完成设置')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('should force LLM config when connection test fails', async () => {
|
||||
mockFetchStatus.mockResolvedValue({ configured: true })
|
||||
mockFetchConfig.mockResolvedValue({ provider: 'openai', model: 'gpt-4' })
|
||||
mockTestConnection.mockRejectedValue(new Error('Connection failed'))
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
const TestComponent = createTestComponent()
|
||||
const wrapper = mount(TestComponent)
|
||||
|
||||
await wrapper.vm.performStartupCheck()
|
||||
|
||||
expect(wrapper.vm.showMenu).toBe(true)
|
||||
expect(wrapper.vm.menuDefaultTab).toBe('llm')
|
||||
expect(wrapper.vm.canCloseMenu).toBe(false)
|
||||
expect(mockMessageError).toHaveBeenCalledWith('LLM 连接测试失败,请重新配置')
|
||||
|
||||
consoleSpy.mockRestore()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('should handle status fetch error', async () => {
|
||||
mockFetchStatus.mockRejectedValue(new Error('Network error'))
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
const TestComponent = createTestComponent()
|
||||
const wrapper = mount(TestComponent)
|
||||
|
||||
await wrapper.vm.performStartupCheck()
|
||||
|
||||
expect(wrapper.vm.menuDefaultTab).toBe('llm')
|
||||
expect(wrapper.vm.canCloseMenu).toBe(false)
|
||||
expect(mockMessageError).toHaveBeenCalledWith('无法获取系统状态')
|
||||
|
||||
consoleSpy.mockRestore()
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleLLMReady', () => {
|
||||
it('should enable menu close and show success message', () => {
|
||||
const TestComponent = createTestComponent()
|
||||
const wrapper = mount(TestComponent)
|
||||
|
||||
wrapper.vm.canCloseMenu = false
|
||||
|
||||
wrapper.vm.handleLLMReady()
|
||||
|
||||
expect(wrapper.vm.canCloseMenu).toBe(true)
|
||||
expect(wrapper.vm.menuDefaultTab).toBe('start')
|
||||
expect(mockMessageSuccess).toHaveBeenCalledWith('LLM 配置成功,请开始游戏')
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleMenuClose', () => {
|
||||
it('should close menu when closable', () => {
|
||||
const TestComponent = createTestComponent()
|
||||
const wrapper = mount(TestComponent)
|
||||
|
||||
wrapper.vm.showMenu = true
|
||||
wrapper.vm.canCloseMenu = true
|
||||
|
||||
wrapper.vm.handleMenuClose()
|
||||
|
||||
expect(wrapper.vm.showMenu).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('should not close menu when not closable', () => {
|
||||
const TestComponent = createTestComponent()
|
||||
const wrapper = mount(TestComponent)
|
||||
|
||||
wrapper.vm.showMenu = true
|
||||
wrapper.vm.canCloseMenu = false
|
||||
|
||||
wrapper.vm.handleMenuClose()
|
||||
|
||||
expect(wrapper.vm.showMenu).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('toggleManualPause', () => {
|
||||
it('should call systemStore.togglePause', () => {
|
||||
const togglePauseSpy = vi.spyOn(systemStore, 'togglePause').mockResolvedValue(undefined)
|
||||
|
||||
const TestComponent = createTestComponent()
|
||||
const wrapper = mount(TestComponent)
|
||||
|
||||
wrapper.vm.toggleManualPause()
|
||||
|
||||
expect(togglePauseSpy).toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('openLLMConfig', () => {
|
||||
it('should open menu with llm tab', () => {
|
||||
const TestComponent = createTestComponent()
|
||||
const wrapper = mount(TestComponent)
|
||||
|
||||
wrapper.vm.showMenu = false
|
||||
|
||||
wrapper.vm.openLLMConfig()
|
||||
|
||||
expect(wrapper.vm.menuDefaultTab).toBe('llm')
|
||||
expect(wrapper.vm.showMenu).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
})
|
||||
147
web/src/__tests__/composables/useGameInit.test.ts
Normal file
147
web/src/__tests__/composables/useGameInit.test.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { defineComponent, nextTick } from 'vue'
|
||||
import { useSystemStore } from '@/stores/system'
|
||||
import { useWorldStore } from '@/stores/world'
|
||||
import { useSocketStore } from '@/stores/socket'
|
||||
import type { InitStatusDTO } from '@/types/api'
|
||||
|
||||
// Use vi.hoisted to define mocks before vi.mock is hoisted.
|
||||
const { mockLoadBaseTextures } = vi.hoisted(() => ({
|
||||
mockLoadBaseTextures: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
// Mock useTextures composable.
|
||||
vi.mock('@/components/game/composables/useTextures', () => ({
|
||||
useTextures: () => ({
|
||||
loadBaseTextures: mockLoadBaseTextures,
|
||||
}),
|
||||
}))
|
||||
|
||||
import { useGameInit } from '@/composables/useGameInit'
|
||||
|
||||
const createMockStatus = (overrides: Partial<InitStatusDTO> = {}): InitStatusDTO => ({
|
||||
status: 'idle',
|
||||
phase: 0,
|
||||
phase_name: '',
|
||||
progress: 0,
|
||||
elapsed_seconds: 0,
|
||||
error: null,
|
||||
llm_check_failed: false,
|
||||
llm_error_message: '',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
// Helper to create test component.
|
||||
const createTestComponent = (options: { onIdle?: () => void } = {}) => {
|
||||
return defineComponent({
|
||||
setup() {
|
||||
const result = useGameInit(options)
|
||||
return { ...result }
|
||||
},
|
||||
template: '<div></div>'
|
||||
})
|
||||
}
|
||||
|
||||
describe('useGameInit', () => {
|
||||
let systemStore: ReturnType<typeof useSystemStore>
|
||||
let worldStore: ReturnType<typeof useWorldStore>
|
||||
let socketStore: ReturnType<typeof useSocketStore>
|
||||
|
||||
beforeEach(() => {
|
||||
systemStore = useSystemStore()
|
||||
worldStore = useWorldStore()
|
||||
socketStore = useSocketStore()
|
||||
vi.clearAllMocks()
|
||||
// Ensure systemStore.fetchInitStatus returns immediately.
|
||||
vi.spyOn(systemStore, 'fetchInitStatus').mockResolvedValue(createMockStatus())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers()
|
||||
})
|
||||
|
||||
describe('initial state', () => {
|
||||
it('should expose startPolling and stopPolling functions', () => {
|
||||
const TestComponent = createTestComponent()
|
||||
const wrapper = mount(TestComponent)
|
||||
|
||||
expect(typeof wrapper.vm.startPolling).toBe('function')
|
||||
expect(typeof wrapper.vm.stopPolling).toBe('function')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('should expose initializeGame function', () => {
|
||||
const TestComponent = createTestComponent()
|
||||
const wrapper = mount(TestComponent)
|
||||
|
||||
expect(typeof wrapper.vm.initializeGame).toBe('function')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('should expose initStatus ref from store', () => {
|
||||
const TestComponent = createTestComponent()
|
||||
const wrapper = mount(TestComponent)
|
||||
|
||||
expect(wrapper.vm.initStatus).toBeDefined()
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('should have mapPreloaded initially false', () => {
|
||||
const TestComponent = createTestComponent()
|
||||
const wrapper = mount(TestComponent)
|
||||
|
||||
expect(wrapper.vm.mapPreloaded).toBe(false)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('should have avatarsPreloaded initially false', () => {
|
||||
const TestComponent = createTestComponent()
|
||||
const wrapper = mount(TestComponent)
|
||||
|
||||
expect(wrapper.vm.avatarsPreloaded).toBe(false)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('lifecycle', () => {
|
||||
it('should disconnect socket on unmount', async () => {
|
||||
const disconnectSpy = vi.spyOn(socketStore, 'disconnect')
|
||||
|
||||
const TestComponent = createTestComponent()
|
||||
const wrapper = mount(TestComponent)
|
||||
await nextTick()
|
||||
|
||||
wrapper.unmount()
|
||||
|
||||
expect(disconnectSpy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('gameInitialized alias', () => {
|
||||
it('should expose gameInitialized as alias for isInitialized', () => {
|
||||
const TestComponent = createTestComponent()
|
||||
const wrapper = mount(TestComponent)
|
||||
|
||||
expect(wrapper.vm.gameInitialized).toBeDefined()
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('showLoading', () => {
|
||||
it('should expose showLoading from store', () => {
|
||||
const TestComponent = createTestComponent()
|
||||
const wrapper = mount(TestComponent)
|
||||
|
||||
expect(wrapper.vm.showLoading).toBeDefined()
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
})
|
||||
156
web/src/__tests__/composables/useSharedTicker.test.ts
Normal file
156
web/src/__tests__/composables/useSharedTicker.test.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { defineComponent, nextTick } from 'vue'
|
||||
|
||||
// Use vi.hoisted to define mocks before vi.mock is hoisted.
|
||||
const { mockAdd, mockRemove, mockStart, mockStop, mockTickerCallbacks, resetMockState } = vi.hoisted(() => {
|
||||
const callbacks: Set<(ticker: any) => void> = new Set()
|
||||
let started = false
|
||||
|
||||
return {
|
||||
mockTickerCallbacks: callbacks,
|
||||
mockAdd: vi.fn((cb) => { callbacks.add(cb) }),
|
||||
mockRemove: vi.fn((cb) => { callbacks.delete(cb) }),
|
||||
mockStart: vi.fn(() => { started = true }),
|
||||
mockStop: vi.fn(() => { started = false }),
|
||||
resetMockState: () => {
|
||||
callbacks.clear()
|
||||
started = false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Mock pixi.js Ticker.
|
||||
vi.mock('pixi.js', () => ({
|
||||
Ticker: vi.fn().mockImplementation(() => ({
|
||||
add: mockAdd,
|
||||
remove: mockRemove,
|
||||
start: mockStart,
|
||||
stop: mockStop,
|
||||
get started() { return false },
|
||||
deltaTime: 16.67,
|
||||
})),
|
||||
}))
|
||||
|
||||
// Import after mocking.
|
||||
import { useSharedTicker } from '@/components/game/composables/useSharedTicker'
|
||||
|
||||
// Create a test component that uses the composable.
|
||||
const createTestComponent = (callback: (delta: number) => void) => {
|
||||
return defineComponent({
|
||||
setup() {
|
||||
useSharedTicker(callback)
|
||||
return {}
|
||||
},
|
||||
template: '<div></div>'
|
||||
})
|
||||
}
|
||||
|
||||
describe('useSharedTicker', () => {
|
||||
beforeEach(() => {
|
||||
resetMockState()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('lifecycle', () => {
|
||||
it('should add callback on mount', async () => {
|
||||
const callback = vi.fn()
|
||||
const TestComponent = createTestComponent(callback)
|
||||
|
||||
const wrapper = mount(TestComponent)
|
||||
await nextTick()
|
||||
|
||||
expect(mockAdd).toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('should start ticker on first consumer mount', async () => {
|
||||
const callback = vi.fn()
|
||||
const TestComponent = createTestComponent(callback)
|
||||
|
||||
const wrapper = mount(TestComponent)
|
||||
await nextTick()
|
||||
|
||||
expect(mockStart).toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('should remove callback on unmount', async () => {
|
||||
const callback = vi.fn()
|
||||
const TestComponent = createTestComponent(callback)
|
||||
|
||||
const wrapper = mount(TestComponent)
|
||||
await nextTick()
|
||||
|
||||
wrapper.unmount()
|
||||
await nextTick()
|
||||
|
||||
expect(mockRemove).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should stop ticker when last consumer unmounts', async () => {
|
||||
const callback = vi.fn()
|
||||
const TestComponent = createTestComponent(callback)
|
||||
|
||||
const wrapper = mount(TestComponent)
|
||||
await nextTick()
|
||||
|
||||
wrapper.unmount()
|
||||
await nextTick()
|
||||
|
||||
expect(mockStop).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('multiple consumers', () => {
|
||||
it('should handle multiple consumers', async () => {
|
||||
const callback1 = vi.fn()
|
||||
const callback2 = vi.fn()
|
||||
const TestComponent1 = createTestComponent(callback1)
|
||||
const TestComponent2 = createTestComponent(callback2)
|
||||
|
||||
const wrapper1 = mount(TestComponent1)
|
||||
await nextTick()
|
||||
|
||||
const wrapper2 = mount(TestComponent2)
|
||||
await nextTick()
|
||||
|
||||
// Both callbacks should be added.
|
||||
expect(mockAdd).toHaveBeenCalledTimes(2)
|
||||
|
||||
// Unmount first consumer.
|
||||
wrapper1.unmount()
|
||||
await nextTick()
|
||||
|
||||
// Ticker should still have one consumer.
|
||||
expect(mockTickerCallbacks.size).toBe(1)
|
||||
|
||||
// Unmount second consumer.
|
||||
wrapper2.unmount()
|
||||
await nextTick()
|
||||
|
||||
// Now ticker should stop.
|
||||
expect(mockStop).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('callback invocation', () => {
|
||||
it('should pass delta time to callback when ticker fires', async () => {
|
||||
const callback = vi.fn()
|
||||
const TestComponent = createTestComponent(callback)
|
||||
|
||||
const wrapper = mount(TestComponent)
|
||||
await nextTick()
|
||||
|
||||
// Simulate a tick by calling all registered callbacks.
|
||||
mockTickerCallbacks.forEach(cb => cb({ deltaTime: 16.67 }))
|
||||
|
||||
expect(callback).toHaveBeenCalledWith(16.67)
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
})
|
||||
418
web/src/__tests__/composables/useTextures.test.ts
Normal file
418
web/src/__tests__/composables/useTextures.test.ts
Normal file
@@ -0,0 +1,418 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
// Use vi.hoisted to define mocks before vi.mock is hoisted.
|
||||
const { mockAssetsLoad, mockFetchAvatarMeta, mockGetClusteredTileVariant } = vi.hoisted(() => ({
|
||||
mockAssetsLoad: vi.fn().mockResolvedValue({ valid: true }),
|
||||
mockFetchAvatarMeta: vi.fn(),
|
||||
mockGetClusteredTileVariant: vi.fn((x: number, y: number, count: number) => (x + y) % count),
|
||||
}))
|
||||
|
||||
// Mock pixi.js.
|
||||
vi.mock('pixi.js', () => ({
|
||||
Assets: {
|
||||
load: mockAssetsLoad,
|
||||
},
|
||||
Texture: {},
|
||||
TextureStyle: {
|
||||
defaultOptions: {
|
||||
scaleMode: 'linear',
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock avatar API.
|
||||
vi.mock('@/api', () => ({
|
||||
avatarApi: {
|
||||
fetchAvatarMeta: mockFetchAvatarMeta,
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock procedural utils.
|
||||
vi.mock('@/utils/procedural', () => ({
|
||||
getClusteredTileVariant: mockGetClusteredTileVariant,
|
||||
}))
|
||||
|
||||
import { useTextures } from '@/components/game/composables/useTextures'
|
||||
|
||||
describe('useTextures', () => {
|
||||
// Get references to shared state for resetting between tests.
|
||||
let texturesInstance: ReturnType<typeof useTextures>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockAssetsLoad.mockResolvedValue({ valid: true })
|
||||
|
||||
// Get instance and reset shared state.
|
||||
texturesInstance = useTextures()
|
||||
|
||||
// Clear all textures to force reload.
|
||||
Object.keys(texturesInstance.textures.value).forEach(key => {
|
||||
delete texturesInstance.textures.value[key]
|
||||
})
|
||||
|
||||
// Reset isLoaded to false to allow reloading.
|
||||
texturesInstance.isLoaded.value = false
|
||||
|
||||
// Reset available avatars.
|
||||
texturesInstance.availableAvatars.value = { males: [], females: [] }
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('return values', () => {
|
||||
it('should return textures ref', () => {
|
||||
const { textures } = useTextures()
|
||||
expect(textures).toBeDefined()
|
||||
expect(textures.value).toBeDefined()
|
||||
})
|
||||
|
||||
it('should return isLoaded ref', () => {
|
||||
const { isLoaded } = useTextures()
|
||||
expect(isLoaded).toBeDefined()
|
||||
})
|
||||
|
||||
it('should return availableAvatars ref', () => {
|
||||
const { availableAvatars } = useTextures()
|
||||
expect(availableAvatars).toBeDefined()
|
||||
expect(availableAvatars.value).toHaveProperty('males')
|
||||
expect(availableAvatars.value).toHaveProperty('females')
|
||||
})
|
||||
|
||||
it('should return loadBaseTextures function', () => {
|
||||
const { loadBaseTextures } = useTextures()
|
||||
expect(typeof loadBaseTextures).toBe('function')
|
||||
})
|
||||
|
||||
it('should return loadSectTexture function', () => {
|
||||
const { loadSectTexture } = useTextures()
|
||||
expect(typeof loadSectTexture).toBe('function')
|
||||
})
|
||||
|
||||
it('should return loadCityTexture function', () => {
|
||||
const { loadCityTexture } = useTextures()
|
||||
expect(typeof loadCityTexture).toBe('function')
|
||||
})
|
||||
|
||||
it('should return getTileTexture function', () => {
|
||||
const { getTileTexture } = useTextures()
|
||||
expect(typeof getTileTexture).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadBaseTextures', () => {
|
||||
it('should fetch avatar meta', async () => {
|
||||
mockFetchAvatarMeta.mockResolvedValue({
|
||||
males: [1, 2, 3],
|
||||
females: [1, 2],
|
||||
})
|
||||
|
||||
const { loadBaseTextures } = useTextures()
|
||||
await loadBaseTextures()
|
||||
|
||||
expect(mockFetchAvatarMeta).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should load base tile textures', async () => {
|
||||
mockFetchAvatarMeta.mockResolvedValue({
|
||||
males: [],
|
||||
females: [],
|
||||
})
|
||||
|
||||
const { loadBaseTextures } = useTextures()
|
||||
await loadBaseTextures()
|
||||
|
||||
// Should load various tile textures.
|
||||
expect(mockAssetsLoad).toHaveBeenCalledWith('/assets/tiles/plain.png')
|
||||
expect(mockAssetsLoad).toHaveBeenCalledWith('/assets/tiles/water.png')
|
||||
expect(mockAssetsLoad).toHaveBeenCalledWith('/assets/tiles/city.png')
|
||||
})
|
||||
|
||||
it('should load tile variants', async () => {
|
||||
mockFetchAvatarMeta.mockResolvedValue({
|
||||
males: [],
|
||||
females: [],
|
||||
})
|
||||
|
||||
const { loadBaseTextures } = useTextures()
|
||||
await loadBaseTextures()
|
||||
|
||||
// Should load variant textures (e.g., forest_0, forest_1, etc.).
|
||||
expect(mockAssetsLoad).toHaveBeenCalledWith('/assets/tiles/forest_0.png')
|
||||
expect(mockAssetsLoad).toHaveBeenCalledWith('/assets/tiles/mountain_0.png')
|
||||
})
|
||||
|
||||
it('should load cloud textures', async () => {
|
||||
mockFetchAvatarMeta.mockResolvedValue({
|
||||
males: [],
|
||||
females: [],
|
||||
})
|
||||
|
||||
const { loadBaseTextures } = useTextures()
|
||||
await loadBaseTextures()
|
||||
|
||||
expect(mockAssetsLoad).toHaveBeenCalledWith('/assets/clouds/cloud_0.png')
|
||||
expect(mockAssetsLoad).toHaveBeenCalledWith('/assets/clouds/cloud_8.png')
|
||||
})
|
||||
|
||||
it('should load avatar textures based on meta', async () => {
|
||||
mockFetchAvatarMeta.mockResolvedValue({
|
||||
males: [1, 5, 10],
|
||||
females: [2, 7],
|
||||
})
|
||||
|
||||
const { loadBaseTextures } = useTextures()
|
||||
await loadBaseTextures()
|
||||
|
||||
expect(mockAssetsLoad).toHaveBeenCalledWith('/assets/males/1.png')
|
||||
expect(mockAssetsLoad).toHaveBeenCalledWith('/assets/males/5.png')
|
||||
expect(mockAssetsLoad).toHaveBeenCalledWith('/assets/males/10.png')
|
||||
expect(mockAssetsLoad).toHaveBeenCalledWith('/assets/females/2.png')
|
||||
expect(mockAssetsLoad).toHaveBeenCalledWith('/assets/females/7.png')
|
||||
})
|
||||
|
||||
it('should use fallback avatar range when meta fetch fails', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
mockFetchAvatarMeta.mockRejectedValue(new Error('Network error'))
|
||||
|
||||
const { loadBaseTextures, availableAvatars } = useTextures()
|
||||
await loadBaseTextures()
|
||||
|
||||
// Should use fallback range.
|
||||
expect(availableAvatars.value.males.length).toBe(47)
|
||||
expect(availableAvatars.value.females.length).toBe(41)
|
||||
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should set isLoaded to true after loading', async () => {
|
||||
mockFetchAvatarMeta.mockResolvedValue({
|
||||
males: [],
|
||||
females: [],
|
||||
})
|
||||
|
||||
const { loadBaseTextures, isLoaded } = useTextures()
|
||||
await loadBaseTextures()
|
||||
|
||||
expect(isLoaded.value).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle individual texture load failures gracefully', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
mockFetchAvatarMeta.mockResolvedValue({
|
||||
males: [],
|
||||
females: [],
|
||||
})
|
||||
mockAssetsLoad.mockImplementation((url: string) => {
|
||||
if (url.includes('plain')) {
|
||||
return Promise.reject(new Error('Load failed'))
|
||||
}
|
||||
return Promise.resolve({ valid: true })
|
||||
})
|
||||
|
||||
const { loadBaseTextures, isLoaded } = useTextures()
|
||||
await loadBaseTextures()
|
||||
|
||||
// Should still complete and set isLoaded.
|
||||
expect(isLoaded.value).toBe(true)
|
||||
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadSectTexture', () => {
|
||||
it('should load 4 sect texture slices', async () => {
|
||||
const { loadSectTexture, textures } = useTextures()
|
||||
|
||||
// Clear any existing sect textures.
|
||||
delete textures.value['sect_1_0']
|
||||
delete textures.value['sect_1_1']
|
||||
delete textures.value['sect_1_2']
|
||||
delete textures.value['sect_1_3']
|
||||
|
||||
await loadSectTexture(1)
|
||||
|
||||
expect(mockAssetsLoad).toHaveBeenCalledWith('/assets/sects/sect_1_0.png')
|
||||
expect(mockAssetsLoad).toHaveBeenCalledWith('/assets/sects/sect_1_1.png')
|
||||
expect(mockAssetsLoad).toHaveBeenCalledWith('/assets/sects/sect_1_2.png')
|
||||
expect(mockAssetsLoad).toHaveBeenCalledWith('/assets/sects/sect_1_3.png')
|
||||
})
|
||||
|
||||
it('should store sect textures in cache', async () => {
|
||||
const { loadSectTexture, textures } = useTextures()
|
||||
|
||||
await loadSectTexture(5)
|
||||
|
||||
expect(textures.value['sect_5_0']).toBeDefined()
|
||||
expect(textures.value['sect_5_1']).toBeDefined()
|
||||
expect(textures.value['sect_5_2']).toBeDefined()
|
||||
expect(textures.value['sect_5_3']).toBeDefined()
|
||||
})
|
||||
|
||||
it('should not reload already cached textures', async () => {
|
||||
const { loadSectTexture, textures } = useTextures()
|
||||
|
||||
// Pre-populate cache.
|
||||
textures.value['sect_3_0'] = { valid: true } as any
|
||||
textures.value['sect_3_1'] = { valid: true } as any
|
||||
textures.value['sect_3_2'] = { valid: true } as any
|
||||
textures.value['sect_3_3'] = { valid: true } as any
|
||||
|
||||
vi.clearAllMocks()
|
||||
await loadSectTexture(3)
|
||||
|
||||
// Should not call Assets.load for already cached textures.
|
||||
expect(mockAssetsLoad).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadCityTexture', () => {
|
||||
it('should load 4 city texture slices', async () => {
|
||||
const { loadCityTexture } = useTextures()
|
||||
|
||||
await loadCityTexture(2)
|
||||
|
||||
// Should try jpg extension first.
|
||||
expect(mockAssetsLoad).toHaveBeenCalledWith('/assets/cities/city_2_0.jpg')
|
||||
})
|
||||
|
||||
it('should store city textures in cache', async () => {
|
||||
const { loadCityTexture, textures } = useTextures()
|
||||
|
||||
await loadCityTexture(7)
|
||||
|
||||
expect(textures.value['city_7_0']).toBeDefined()
|
||||
})
|
||||
|
||||
it('should try png if jpg fails', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
mockAssetsLoad.mockImplementation((url: string) => {
|
||||
if (url.endsWith('.jpg')) {
|
||||
return Promise.reject(new Error('Not found'))
|
||||
}
|
||||
return Promise.resolve({ valid: true })
|
||||
})
|
||||
|
||||
const { loadCityTexture } = useTextures()
|
||||
|
||||
await loadCityTexture(4)
|
||||
|
||||
// Should have tried png after jpg failed.
|
||||
expect(mockAssetsLoad).toHaveBeenCalledWith('/assets/cities/city_4_0.jpg')
|
||||
expect(mockAssetsLoad).toHaveBeenCalledWith('/assets/cities/city_4_0.png')
|
||||
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getTileTexture', () => {
|
||||
it('should return base texture for non-variant types', () => {
|
||||
const { getTileTexture, textures } = useTextures()
|
||||
|
||||
const plainTexture = { valid: true, type: 'plain' }
|
||||
textures.value['PLAIN'] = plainTexture as any
|
||||
|
||||
const result = getTileTexture('PLAIN', 0, 0)
|
||||
|
||||
// Use toEqual for object comparison.
|
||||
expect(result).toEqual(plainTexture)
|
||||
})
|
||||
|
||||
it('should return variant texture for variant types', () => {
|
||||
const { getTileTexture, textures } = useTextures()
|
||||
|
||||
// Set up variant textures.
|
||||
textures.value['FOREST_0'] = { valid: true, variant: 0 } as any
|
||||
textures.value['FOREST_1'] = { valid: true, variant: 1 } as any
|
||||
|
||||
// Mock getClusteredTileVariant to return 1.
|
||||
mockGetClusteredTileVariant.mockReturnValue(1)
|
||||
|
||||
const result = getTileTexture('FOREST', 5, 5)
|
||||
|
||||
expect(mockGetClusteredTileVariant).toHaveBeenCalledWith(5, 5, 9)
|
||||
expect(result).toEqual({ valid: true, variant: 1 })
|
||||
})
|
||||
|
||||
it('should fallback to base texture if variant not found', () => {
|
||||
const { getTileTexture, textures } = useTextures()
|
||||
|
||||
// Only set base texture, no variants.
|
||||
const glacierTexture = { valid: true, type: 'glacier' }
|
||||
textures.value['GLACIER'] = glacierTexture as any
|
||||
// Clear any variant.
|
||||
delete textures.value['GLACIER_5']
|
||||
|
||||
mockGetClusteredTileVariant.mockReturnValue(5)
|
||||
|
||||
const result = getTileTexture('GLACIER', 0, 0)
|
||||
|
||||
expect(result).toEqual(glacierTexture)
|
||||
})
|
||||
|
||||
it('should return undefined for unknown texture types', () => {
|
||||
const { getTileTexture, textures } = useTextures()
|
||||
|
||||
// Ensure texture doesn't exist.
|
||||
delete textures.value['UNKNOWN_TYPE']
|
||||
|
||||
const result = getTileTexture('UNKNOWN_TYPE', 0, 0)
|
||||
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('texture caching', () => {
|
||||
it('should share texture cache between composable instances', () => {
|
||||
const instance1 = useTextures()
|
||||
const instance2 = useTextures()
|
||||
|
||||
instance1.textures.value['shared_test_texture'] = { valid: true } as any
|
||||
|
||||
// Both instances should see the same texture.
|
||||
expect(instance2.textures.value['shared_test_texture']).toBeDefined()
|
||||
})
|
||||
|
||||
it('should share isLoaded state between instances', () => {
|
||||
const instance1 = useTextures()
|
||||
const instance2 = useTextures()
|
||||
|
||||
// Both should reference the same isLoaded.
|
||||
expect(instance1.isLoaded).toBe(instance2.isLoaded)
|
||||
})
|
||||
})
|
||||
|
||||
describe('meta change detection', () => {
|
||||
it('should detect and update when avatar meta changes', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
// First call with initial meta.
|
||||
mockFetchAvatarMeta.mockResolvedValueOnce({
|
||||
males: [1, 2],
|
||||
females: [1],
|
||||
})
|
||||
|
||||
const { loadBaseTextures, availableAvatars, isLoaded } = useTextures()
|
||||
|
||||
await loadBaseTextures()
|
||||
|
||||
expect(availableAvatars.value.males).toEqual([1, 2])
|
||||
expect(availableAvatars.value.females).toEqual([1])
|
||||
|
||||
// Second call with different meta - reset isLoaded first.
|
||||
isLoaded.value = false
|
||||
mockFetchAvatarMeta.mockResolvedValueOnce({
|
||||
males: [1, 2, 3],
|
||||
females: [1, 2],
|
||||
})
|
||||
|
||||
await loadBaseTextures()
|
||||
|
||||
expect(availableAvatars.value.males).toEqual([1, 2, 3])
|
||||
expect(availableAvatars.value.females).toEqual([1, 2])
|
||||
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user