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