diff --git a/web/src/__tests__/composables/useGameControl.test.ts b/web/src/__tests__/composables/useGameControl.test.ts
new file mode 100644
index 0000000..dd0b4e0
--- /dev/null
+++ b/web/src/__tests__/composables/useGameControl.test.ts
@@ -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: '
'
+ })
+}
+
+describe('useGameControl', () => {
+ let uiStore: ReturnType
+ let systemStore: ReturnType
+
+ 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()
+ })
+ })
+})
diff --git a/web/src/__tests__/composables/useGameInit.test.ts b/web/src/__tests__/composables/useGameInit.test.ts
new file mode 100644
index 0000000..a25b50e
--- /dev/null
+++ b/web/src/__tests__/composables/useGameInit.test.ts
@@ -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 => ({
+ 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: ''
+ })
+}
+
+describe('useGameInit', () => {
+ let systemStore: ReturnType
+ let worldStore: ReturnType
+ let socketStore: ReturnType
+
+ 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()
+ })
+ })
+})
diff --git a/web/src/__tests__/composables/useSharedTicker.test.ts b/web/src/__tests__/composables/useSharedTicker.test.ts
new file mode 100644
index 0000000..8f133f5
--- /dev/null
+++ b/web/src/__tests__/composables/useSharedTicker.test.ts
@@ -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: ''
+ })
+}
+
+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()
+ })
+ })
+})
diff --git a/web/src/__tests__/composables/useTextures.test.ts b/web/src/__tests__/composables/useTextures.test.ts
new file mode 100644
index 0000000..c4a3c29
--- /dev/null
+++ b/web/src/__tests__/composables/useTextures.test.ts
@@ -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
+
+ 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()
+ })
+ })
+})