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:
Zihao Xu
2026-01-25 01:17:02 -08:00
committed by GitHub
parent 5fa8016334
commit 2b5ec24455
4 changed files with 1020 additions and 0 deletions

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

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

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

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