From f15ee9455924f310ca9641f57f2647b0254056d3 Mon Sep 17 00:00:00 2001 From: Zihao Xu Date: Wed, 4 Feb 2026 01:01:58 -0800 Subject: [PATCH] test(web): add comprehensive frontend tests for Phase 3 (#129) - Add component tests: EntityRow, RelationRow, StatItem, StatusBar, StatusWidget, TagList - Add store tests: setting - Enhance existing tests: socket (toast, alert handling), useGameInit (polling, initialization) - Update vitest.config.ts to exclude config files from coverage - Coverage improved from 13.39% to 32.49% (tested files achieve 95%+ coverage) --- .../__tests__/components/EntityRow.test.ts | 175 ++++++++ .../__tests__/components/RelationRow.test.ts | 95 +++++ web/src/__tests__/components/StatItem.test.ts | 160 ++++++++ .../__tests__/components/StatusBar.test.ts | 388 ++++++++++++++++++ .../__tests__/components/StatusWidget.test.ts | 290 +++++++++++++ web/src/__tests__/components/TagList.test.ts | 163 ++++++++ .../__tests__/composables/useGameInit.test.ts | 250 +++++++++++ web/src/__tests__/stores/setting.test.ts | 161 ++++++++ web/src/__tests__/stores/socket.test.ts | 245 ++++++++++- web/vitest.config.ts | 2 + 10 files changed, 1914 insertions(+), 15 deletions(-) create mode 100644 web/src/__tests__/components/EntityRow.test.ts create mode 100644 web/src/__tests__/components/RelationRow.test.ts create mode 100644 web/src/__tests__/components/StatItem.test.ts create mode 100644 web/src/__tests__/components/StatusBar.test.ts create mode 100644 web/src/__tests__/components/StatusWidget.test.ts create mode 100644 web/src/__tests__/components/TagList.test.ts create mode 100644 web/src/__tests__/stores/setting.test.ts diff --git a/web/src/__tests__/components/EntityRow.test.ts b/web/src/__tests__/components/EntityRow.test.ts new file mode 100644 index 0000000..e32b182 --- /dev/null +++ b/web/src/__tests__/components/EntityRow.test.ts @@ -0,0 +1,175 @@ +import { describe, it, expect, vi } from 'vitest' +import { mount } from '@vue/test-utils' + +// Mock getEntityColor. +const mockGetEntityColor = vi.fn() + +vi.mock('@/utils/theme', () => ({ + getEntityColor: (entity: any) => mockGetEntityColor(entity), +})) + +import EntityRow from '@/components/game/panels/info/components/EntityRow.vue' + +describe('EntityRow', () => { + const defaultItem = { + id: '1', + name: 'Test Entity', + } + + beforeEach(() => { + vi.clearAllMocks() + mockGetEntityColor.mockReturnValue('#ff0000') + }) + + it('should render item name', () => { + const wrapper = mount(EntityRow, { + props: { + item: defaultItem, + }, + }) + + expect(wrapper.find('.name').text()).toBe('Test Entity') + }) + + it('should apply color from getEntityColor', () => { + mockGetEntityColor.mockReturnValue('#00ff00') + + const wrapper = mount(EntityRow, { + props: { + item: defaultItem, + }, + }) + + expect(mockGetEntityColor).toHaveBeenCalledWith(defaultItem) + expect(wrapper.find('.name').attributes('style')).toContain('color: rgb(0, 255, 0)') + }) + + it('should render meta when provided', () => { + const wrapper = mount(EntityRow, { + props: { + item: defaultItem, + meta: 'Proficiency 50%', + }, + }) + + expect(wrapper.find('.meta').exists()).toBe(true) + expect(wrapper.find('.meta').text()).toBe('Proficiency 50%') + }) + + it('should hide meta when not provided', () => { + const wrapper = mount(EntityRow, { + props: { + item: defaultItem, + }, + }) + + expect(wrapper.find('.meta').exists()).toBe(false) + }) + + it('should render grade when item has grade', () => { + const itemWithGrade = { + ...defaultItem, + grade: 'SSR', + } + + const wrapper = mount(EntityRow, { + props: { + item: itemWithGrade, + }, + }) + + expect(wrapper.find('.grade').exists()).toBe(true) + expect(wrapper.find('.grade').text()).toBe('SSR') + }) + + it('should hide grade when item has no grade', () => { + const wrapper = mount(EntityRow, { + props: { + item: defaultItem, + }, + }) + + expect(wrapper.find('.grade').exists()).toBe(false) + }) + + it('should have compact class when compact is true', () => { + const wrapper = mount(EntityRow, { + props: { + item: defaultItem, + compact: true, + }, + }) + + expect(wrapper.find('.entity-row').classes()).toContain('compact') + }) + + it('should not have compact class when compact is false', () => { + const wrapper = mount(EntityRow, { + props: { + item: defaultItem, + compact: false, + }, + }) + + expect(wrapper.find('.entity-row').classes()).not.toContain('compact') + }) + + it('should not have compact class when compact not provided', () => { + const wrapper = mount(EntityRow, { + props: { + item: defaultItem, + }, + }) + + expect(wrapper.find('.entity-row').classes()).not.toContain('compact') + }) + + it('should emit click on click', async () => { + const wrapper = mount(EntityRow, { + props: { + item: defaultItem, + }, + }) + + await wrapper.find('.entity-row').trigger('click') + + expect(wrapper.emitted('click')).toBeTruthy() + expect(wrapper.emitted('click')?.length).toBe(1) + }) + + it('should render all props together', () => { + mockGetEntityColor.mockReturnValue('#0000ff') + + const itemWithGrade = { + id: '2', + name: 'Full Entity', + grade: 'SR', + } + + const wrapper = mount(EntityRow, { + props: { + item: itemWithGrade, + meta: 'Meta Info', + compact: true, + }, + }) + + expect(wrapper.find('.name').text()).toBe('Full Entity') + expect(wrapper.find('.meta').text()).toBe('Meta Info') + expect(wrapper.find('.grade').text()).toBe('SR') + expect(wrapper.find('.entity-row').classes()).toContain('compact') + }) + + it('should handle undefined color from getEntityColor', () => { + mockGetEntityColor.mockReturnValue(undefined) + + const wrapper = mount(EntityRow, { + props: { + item: defaultItem, + }, + }) + + // Should not throw, just render without color. + expect(wrapper.find('.name').exists()).toBe(true) + }) +}) diff --git a/web/src/__tests__/components/RelationRow.test.ts b/web/src/__tests__/components/RelationRow.test.ts new file mode 100644 index 0000000..1b93db1 --- /dev/null +++ b/web/src/__tests__/components/RelationRow.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect } from 'vitest' +import { mount } from '@vue/test-utils' +import RelationRow from '@/components/game/panels/info/components/RelationRow.vue' + +describe('RelationRow', () => { + const defaultProps = { + name: 'Test Name', + } + + it('should render name prop', () => { + const wrapper = mount(RelationRow, { + props: defaultProps, + }) + + expect(wrapper.text()).toContain('Test Name') + expect(wrapper.find('.rel-name').text()).toBe('Test Name') + }) + + it('should render meta when provided', () => { + const wrapper = mount(RelationRow, { + props: { + ...defaultProps, + meta: 'Test Meta', + }, + }) + + expect(wrapper.find('.rel-type').exists()).toBe(true) + expect(wrapper.find('.rel-type').text()).toBe('Test Meta') + }) + + it('should hide meta when not provided', () => { + const wrapper = mount(RelationRow, { + props: defaultProps, + }) + + expect(wrapper.find('.rel-type').exists()).toBe(false) + }) + + it('should render sub when provided', () => { + const wrapper = mount(RelationRow, { + props: { + ...defaultProps, + sub: 'Subtitle text', + }, + }) + + expect(wrapper.find('.rel-sub').exists()).toBe(true) + expect(wrapper.find('.rel-sub').text()).toBe('Subtitle text') + }) + + it('should hide sub when not provided', () => { + const wrapper = mount(RelationRow, { + props: defaultProps, + }) + + expect(wrapper.find('.rel-sub').exists()).toBe(false) + }) + + it('should emit click on click', async () => { + const wrapper = mount(RelationRow, { + props: defaultProps, + }) + + await wrapper.find('.relation-row').trigger('click') + + expect(wrapper.emitted('click')).toBeTruthy() + expect(wrapper.emitted('click')?.length).toBe(1) + }) + + it('should emit multiple clicks', async () => { + const wrapper = mount(RelationRow, { + props: defaultProps, + }) + + await wrapper.find('.relation-row').trigger('click') + await wrapper.find('.relation-row').trigger('click') + await wrapper.find('.relation-row').trigger('click') + + expect(wrapper.emitted('click')?.length).toBe(3) + }) + + it('should render all props together', () => { + const wrapper = mount(RelationRow, { + props: { + name: 'Full Name', + meta: 'Meta Info', + sub: 'Sub Info', + }, + }) + + expect(wrapper.find('.rel-name').text()).toBe('Full Name') + expect(wrapper.find('.rel-type').text()).toBe('Meta Info') + expect(wrapper.find('.rel-sub').text()).toBe('Sub Info') + }) +}) diff --git a/web/src/__tests__/components/StatItem.test.ts b/web/src/__tests__/components/StatItem.test.ts new file mode 100644 index 0000000..075cd53 --- /dev/null +++ b/web/src/__tests__/components/StatItem.test.ts @@ -0,0 +1,160 @@ +import { describe, it, expect, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import StatItem from '@/components/game/panels/info/components/StatItem.vue' + +describe('StatItem', () => { + const defaultProps = { + label: 'Test Label', + value: 'Test Value', + } + + it('should render label', () => { + const wrapper = mount(StatItem, { + props: defaultProps, + }) + + expect(wrapper.find('label').text()).toBe('Test Label') + }) + + it('should render string value', () => { + const wrapper = mount(StatItem, { + props: defaultProps, + }) + + expect(wrapper.find('span').text()).toContain('Test Value') + }) + + it('should render numeric value', () => { + const wrapper = mount(StatItem, { + props: { + label: 'Count', + value: 42, + }, + }) + + expect(wrapper.find('span').text()).toContain('42') + }) + + it('should render subValue when provided', () => { + const wrapper = mount(StatItem, { + props: { + ...defaultProps, + subValue: 'Sub Info', + }, + }) + + expect(wrapper.find('.sub-value').exists()).toBe(true) + expect(wrapper.find('.sub-value').text()).toBe('(Sub Info)') + }) + + it('should render numeric subValue', () => { + const wrapper = mount(StatItem, { + props: { + ...defaultProps, + subValue: 100, + }, + }) + + expect(wrapper.find('.sub-value').text()).toBe('(100)') + }) + + it('should hide subValue when not provided', () => { + const wrapper = mount(StatItem, { + props: defaultProps, + }) + + expect(wrapper.find('.sub-value').exists()).toBe(false) + }) + + it('should have clickable class when onClick provided', () => { + const onClick = vi.fn() + const wrapper = mount(StatItem, { + props: { + ...defaultProps, + onClick, + }, + }) + + expect(wrapper.find('.stat-item').classes()).toContain('clickable') + }) + + it('should not have clickable class when onClick not provided', () => { + const wrapper = mount(StatItem, { + props: defaultProps, + }) + + expect(wrapper.find('.stat-item').classes()).not.toContain('clickable') + }) + + it('should call onClick when clicked', async () => { + const onClick = vi.fn() + const wrapper = mount(StatItem, { + props: { + ...defaultProps, + onClick, + }, + }) + + await wrapper.find('.stat-item').trigger('click') + + expect(onClick).toHaveBeenCalledTimes(1) + }) + + it('should not throw when clicked without onClick', async () => { + const wrapper = mount(StatItem, { + props: defaultProps, + }) + + // Should not throw. + await wrapper.find('.stat-item').trigger('click') + }) + + it('should have full class when fullWidth is true', () => { + const wrapper = mount(StatItem, { + props: { + ...defaultProps, + fullWidth: true, + }, + }) + + expect(wrapper.find('.stat-item').classes()).toContain('full') + }) + + it('should not have full class when fullWidth is false', () => { + const wrapper = mount(StatItem, { + props: { + ...defaultProps, + fullWidth: false, + }, + }) + + expect(wrapper.find('.stat-item').classes()).not.toContain('full') + }) + + it('should not have full class when fullWidth not provided', () => { + const wrapper = mount(StatItem, { + props: defaultProps, + }) + + expect(wrapper.find('.stat-item').classes()).not.toContain('full') + }) + + it('should render all props together', () => { + const onClick = vi.fn() + const wrapper = mount(StatItem, { + props: { + label: 'Full Label', + value: 'Full Value', + subValue: 'Full Sub', + onClick, + fullWidth: true, + }, + }) + + expect(wrapper.find('label').text()).toBe('Full Label') + expect(wrapper.find('span').text()).toContain('Full Value') + expect(wrapper.find('.sub-value').text()).toBe('(Full Sub)') + expect(wrapper.find('.stat-item').classes()).toContain('clickable') + expect(wrapper.find('.stat-item').classes()).toContain('full') + }) +}) diff --git a/web/src/__tests__/components/StatusBar.test.ts b/web/src/__tests__/components/StatusBar.test.ts new file mode 100644 index 0000000..38ece98 --- /dev/null +++ b/web/src/__tests__/components/StatusBar.test.ts @@ -0,0 +1,388 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { h, defineComponent, nextTick } from 'vue' +import { setActivePinia, createPinia } from 'pinia' + +// Use vi.hoisted to define mock functions that will be used by vi.mock. +const { mockGetPhenomenaList, mockChangePhenomenon, mockSuccess, mockError } = vi.hoisted(() => ({ + mockGetPhenomenaList: vi.fn(), + mockChangePhenomenon: vi.fn(), + mockSuccess: vi.fn(), + mockError: vi.fn(), +})) + +// Mutable store state that can be modified in tests. +let mockYear = 100 +let mockMonth = 5 +let mockCurrentPhenomenon: any = { id: 1, name: 'Test Phenomenon', rarity: 'R' } +let mockActiveDomains: any[] = [] +let mockAvatarList: any[] = [{ id: '1' }, { id: '2' }] +let mockPhenomenaList: any[] = [ + { id: 1, name: 'Phenomenon 1', rarity: 'N', desc: 'Desc 1', effect_desc: 'Effect 1' }, + { id: 2, name: 'Phenomenon 2', rarity: 'R', desc: 'Desc 2', effect_desc: 'Effect 2' }, + { id: 3, name: 'Phenomenon 3', rarity: 'SSR', desc: 'Desc 3', effect_desc: 'Effect 3' }, +] +let mockIsConnected = true + +// Mock vue-i18n. +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: (key: string, params?: any) => { + if (params) return `${key}:${JSON.stringify(params)}` + return key + }, + }), +})) + +// Mock stores. +vi.mock('@/stores/world', () => ({ + useWorldStore: () => ({ + get year() { return mockYear }, + get month() { return mockMonth }, + get currentPhenomenon() { return mockCurrentPhenomenon }, + get activeDomains() { return mockActiveDomains }, + get avatarList() { return mockAvatarList }, + get phenomenaList() { return mockPhenomenaList }, + getPhenomenaList: mockGetPhenomenaList, + changePhenomenon: mockChangePhenomenon, + }), +})) + +vi.mock('@/stores/socket', () => ({ + useSocketStore: () => ({ + get isConnected() { return mockIsConnected }, + }), +})) + +// Mock naive-ui. +vi.mock('naive-ui', () => ({ + NModal: defineComponent({ + name: 'NModal', + props: ['show', 'preset', 'title'], + emits: ['update:show'], + setup(props, { slots, emit }) { + return () => props.show ? h('div', { + class: 'n-modal-stub', + onClick: () => emit('update:show', false), + }, slots.default?.()) : null + }, + }), + NList: defineComponent({ + name: 'NList', + props: ['hoverable', 'clickable'], + setup(_, { slots }) { + return () => h('div', { class: 'n-list-stub' }, slots.default?.()) + }, + }), + NListItem: defineComponent({ + name: 'NListItem', + emits: ['click'], + setup(_, { slots, emit }) { + return () => h('div', { + class: 'n-list-item-stub', + onClick: () => emit('click'), + }, slots.default?.()) + }, + }), + NTag: defineComponent({ + name: 'NTag', + props: ['size', 'bordered', 'color'], + setup(_, { slots }) { + return () => h('span', { class: 'n-tag-stub' }, slots.default?.()) + }, + }), + NEmpty: defineComponent({ + name: 'NEmpty', + props: ['description'], + setup(props) { + return () => h('div', { class: 'n-empty-stub' }, props.description) + }, + }), + useMessage: () => ({ + success: mockSuccess, + error: mockError, + }), +})) + +// Stub StatusWidget. +const StatusWidgetStub = defineComponent({ + name: 'StatusWidget', + props: ['label', 'color', 'mode', 'disablePopover', 'title', 'items', 'emptyText'], + emits: ['trigger-click'], + setup(props, { emit }) { + return () => h('div', { + class: 'status-widget-stub', + 'data-label': props.label, + 'data-color': props.color, + onClick: () => emit('trigger-click'), + }, props.label) + }, +}) + +import StatusBar from '@/components/layout/StatusBar.vue' + +describe('StatusBar', () => { + const globalConfig = { + global: { + stubs: { + StatusWidget: StatusWidgetStub, + }, + }, + } + + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + + // Reset mock values. + mockYear = 100 + mockMonth = 5 + mockCurrentPhenomenon = { id: 1, name: 'Test Phenomenon', rarity: 'R' } + mockActiveDomains = [] + mockAvatarList = [{ id: '1' }, { id: '2' }] + mockIsConnected = true + + // Setup default mock implementations. + mockGetPhenomenaList.mockImplementation(() => Promise.resolve()) + mockChangePhenomenon.mockImplementation(() => Promise.resolve()) + }) + + it('should display year and month from worldStore', () => { + mockYear = 200 + mockMonth = 12 + + const wrapper = mount(StatusBar, globalConfig) + + expect(wrapper.text()).toContain('200') + expect(wrapper.text()).toContain('12') + }) + + it('should show connected status when socketStore.isConnected is true', () => { + mockIsConnected = true + + const wrapper = mount(StatusBar, globalConfig) + + expect(wrapper.find('.status-dot.connected').exists()).toBe(true) + }) + + it('should show disconnected status when socketStore.isConnected is false', () => { + mockIsConnected = false + + const wrapper = mount(StatusBar, globalConfig) + + expect(wrapper.find('.status-dot.connected').exists()).toBe(false) + expect(wrapper.find('.status-dot').exists()).toBe(true) + }) + + describe('phenomenonColor', () => { + it('should return #ccc for N rarity', () => { + mockCurrentPhenomenon = { id: 1, name: 'Test', rarity: 'N' } + + const wrapper = mount(StatusBar, globalConfig) + + const widget = wrapper.findAll('.status-widget-stub')[0] + expect(widget.attributes('data-color')).toBe('#ccc') + }) + + it('should return #4dabf7 for R rarity', () => { + mockCurrentPhenomenon = { id: 1, name: 'Test', rarity: 'R' } + + const wrapper = mount(StatusBar, globalConfig) + + const widget = wrapper.findAll('.status-widget-stub')[0] + expect(widget.attributes('data-color')).toBe('#4dabf7') + }) + + it('should return #a0d911 for SR rarity', () => { + mockCurrentPhenomenon = { id: 1, name: 'Test', rarity: 'SR' } + + const wrapper = mount(StatusBar, globalConfig) + + const widget = wrapper.findAll('.status-widget-stub')[0] + expect(widget.attributes('data-color')).toBe('#a0d911') + }) + + it('should return #fa8c16 for SSR rarity', () => { + mockCurrentPhenomenon = { id: 1, name: 'Test', rarity: 'SSR' } + + const wrapper = mount(StatusBar, globalConfig) + + const widget = wrapper.findAll('.status-widget-stub')[0] + expect(widget.attributes('data-color')).toBe('#fa8c16') + }) + + it('should return #ccc for unknown rarity', () => { + mockCurrentPhenomenon = { id: 1, name: 'Test', rarity: 'UNKNOWN' } + + const wrapper = mount(StatusBar, globalConfig) + + const widget = wrapper.findAll('.status-widget-stub')[0] + expect(widget.attributes('data-color')).toBe('#ccc') + }) + + it('should hide phenomenon widget when currentPhenomenon is null', () => { + mockCurrentPhenomenon = null + + const wrapper = mount(StatusBar, globalConfig) + + // When phenomenon is null, v-if hides the widget. + // Only domain widget should exist. + const widgets = wrapper.findAll('.status-widget-stub') + expect(widgets.length).toBe(1) + }) + }) + + describe('phenomenon selector', () => { + it('should call getPhenomenaList when opening selector', async () => { + const wrapper = mount(StatusBar, globalConfig) + + // Trigger click on phenomenon widget. + await wrapper.findAll('.status-widget-stub')[0].trigger('click') + + // Run all pending timers and promises. + await vi.runAllTimersAsync() + await nextTick() + + expect(mockGetPhenomenaList).toHaveBeenCalled() + }) + + it('should show selector modal after getPhenomenaList', async () => { + const wrapper = mount(StatusBar, globalConfig) + + await wrapper.findAll('.status-widget-stub')[0].trigger('click') + await vi.runAllTimersAsync() + await nextTick() + + expect(wrapper.find('.n-modal-stub').exists()).toBe(true) + }) + }) + + describe('changePhenomenon', () => { + it('should call changePhenomenon on selection', async () => { + const wrapper = mount(StatusBar, globalConfig) + + // Open selector. + await wrapper.findAll('.status-widget-stub')[0].trigger('click') + await vi.runAllTimersAsync() + await nextTick() + + // Find and click a list item. + const listItems = wrapper.findAll('.n-list-item-stub') + expect(listItems.length).toBeGreaterThan(0) + + await listItems[0].trigger('click') + await vi.runAllTimersAsync() + await nextTick() + + expect(mockChangePhenomenon).toHaveBeenCalled() + }) + + it('should show success message on successful change', async () => { + mockChangePhenomenon.mockImplementation(() => Promise.resolve()) + + const wrapper = mount(StatusBar, globalConfig) + + await wrapper.findAll('.status-widget-stub')[0].trigger('click') + await vi.runAllTimersAsync() + await nextTick() + + const listItems = wrapper.findAll('.n-list-item-stub') + expect(listItems.length).toBeGreaterThan(0) + + await listItems[0].trigger('click') + await vi.runAllTimersAsync() + await nextTick() + + expect(mockSuccess).toHaveBeenCalled() + }) + + it('should show error message on failed change', async () => { + mockChangePhenomenon.mockImplementation(() => Promise.reject(new Error('Failed'))) + + const wrapper = mount(StatusBar, globalConfig) + + await wrapper.findAll('.status-widget-stub')[0].trigger('click') + await vi.runAllTimersAsync() + await nextTick() + + const listItems = wrapper.findAll('.n-list-item-stub') + expect(listItems.length).toBeGreaterThan(0) + + await listItems[0].trigger('click') + await vi.runAllTimersAsync() + await nextTick() + + expect(mockError).toHaveBeenCalled() + }) + }) + + describe('domain color', () => { + it('should return #fa8c16 when any domain is open', () => { + mockActiveDomains = [ + { id: 1, name: 'D1', is_open: false }, + { id: 2, name: 'D2', is_open: true }, + ] + + const wrapper = mount(StatusBar, globalConfig) + + const domainWidget = wrapper.findAll('.status-widget-stub')[1] + expect(domainWidget.attributes('data-color')).toBe('#fa8c16') + }) + + it('should return #666 when all domains are closed', () => { + mockActiveDomains = [ + { id: 1, name: 'D1', is_open: false }, + { id: 2, name: 'D2', is_open: false }, + ] + + const wrapper = mount(StatusBar, globalConfig) + + const domainWidget = wrapper.findAll('.status-widget-stub')[1] + expect(domainWidget.attributes('data-color')).toBe('#666') + }) + + it('should return #666 when no domains', () => { + mockActiveDomains = [] + + const wrapper = mount(StatusBar, globalConfig) + + const domainWidget = wrapper.findAll('.status-widget-stub')[1] + expect(domainWidget.attributes('data-color')).toBe('#666') + }) + }) + + it('should display cultivator count', () => { + mockAvatarList = [{ id: '1' }, { id: '2' }, { id: '3' }] + + const wrapper = mount(StatusBar, globalConfig) + + // The t function returns key:params format. + expect(wrapper.text()).toContain('game.status_bar.cultivators') + }) + + it('should render external links', () => { + const wrapper = mount(StatusBar, globalConfig) + + const links = wrapper.findAll('a.author-link') + expect(links.length).toBe(2) + expect(links[0].attributes('href')).toContain('bilibili') + expect(links[1].attributes('href')).toContain('github') + }) + + it('should pass correct props to phenomenon StatusWidget', () => { + mockCurrentPhenomenon = { id: 1, name: 'TestPhenomenon', rarity: 'SR' } + + const wrapper = mount(StatusBar, globalConfig) + + const phenomenonWidget = wrapper.findAll('.status-widget-stub')[0] + expect(phenomenonWidget.attributes('data-label')).toBe('[TestPhenomenon]') + expect(phenomenonWidget.attributes('data-color')).toBe('#a0d911') + }) + + it('should pass correct props to domain StatusWidget', () => { + const wrapper = mount(StatusBar, globalConfig) + + const domainWidget = wrapper.findAll('.status-widget-stub')[1] + expect(domainWidget.attributes('data-label')).toBe('game.status_bar.hidden_domain.label') + }) +}) diff --git a/web/src/__tests__/components/StatusWidget.test.ts b/web/src/__tests__/components/StatusWidget.test.ts new file mode 100644 index 0000000..ad0996c --- /dev/null +++ b/web/src/__tests__/components/StatusWidget.test.ts @@ -0,0 +1,290 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount } from '@vue/test-utils' +import { h, defineComponent } from 'vue' + +// Mock vue-i18n. +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: (key: string) => key, + }), +})) + +// Mock naive-ui with stub components. +vi.mock('naive-ui', () => ({ + NPopover: defineComponent({ + name: 'NPopover', + props: ['trigger', 'placement'], + setup(_, { slots }) { + return () => h('div', { class: 'n-popover-stub' }, [ + slots.trigger?.(), + slots.default?.(), + ]) + }, + }), + NList: defineComponent({ + name: 'NList', + props: ['hoverable', 'clickable'], + setup(_, { slots }) { + return () => h('div', { class: 'n-list-stub' }, slots.default?.()) + }, + }), + NListItem: defineComponent({ + name: 'NListItem', + setup(_, { slots }) { + return () => h('div', { class: 'n-list-item-stub' }, slots.default?.()) + }, + }), + NTag: defineComponent({ + name: 'NTag', + props: ['size', 'bordered', 'type'], + setup(_, { slots }) { + return () => h('span', { class: 'n-tag-stub' }, slots.default?.()) + }, + }), + NEmpty: defineComponent({ + name: 'NEmpty', + props: ['description'], + setup(props) { + return () => h('div', { class: 'n-empty-stub' }, props.description) + }, + }), +})) + +import StatusWidget from '@/components/layout/StatusWidget.vue' + +describe('StatusWidget', () => { + const defaultProps = { + label: 'Test Label', + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render label', () => { + const wrapper = mount(StatusWidget, { + props: defaultProps, + }) + + expect(wrapper.text()).toContain('Test Label') + }) + + it('should render label with custom color', () => { + const wrapper = mount(StatusWidget, { + props: { + ...defaultProps, + color: '#ff0000', + }, + }) + + const trigger = wrapper.find('.widget-trigger') + expect(trigger.attributes('style')).toContain('color: rgb(255, 0, 0)') + }) + + it('should use default color when not provided', () => { + const wrapper = mount(StatusWidget, { + props: defaultProps, + }) + + const trigger = wrapper.find('.widget-trigger') + expect(trigger.attributes('style')).toContain('color: rgb(204, 204, 204)') + }) + + it('should emit trigger-click when clicked', async () => { + const wrapper = mount(StatusWidget, { + props: defaultProps, + }) + + await wrapper.find('.widget-trigger').trigger('click') + + expect(wrapper.emitted('trigger-click')).toBeTruthy() + expect(wrapper.emitted('trigger-click')?.length).toBe(1) + }) + + describe('with disablePopover', () => { + it('should skip popover when disablePopover is true', () => { + const wrapper = mount(StatusWidget, { + props: { + ...defaultProps, + disablePopover: true, + }, + }) + + // Should not have popover wrapper. + expect(wrapper.find('.n-popover-stub').exists()).toBe(false) + // But should still have trigger. + expect(wrapper.find('.widget-trigger').exists()).toBe(true) + }) + + it('should emit trigger-click when disablePopover is true', async () => { + const wrapper = mount(StatusWidget, { + props: { + ...defaultProps, + disablePopover: true, + }, + }) + + await wrapper.find('.widget-trigger').trigger('click') + + expect(wrapper.emitted('trigger-click')).toBeTruthy() + }) + }) + + describe('list mode', () => { + const domainItems = [ + { + id: 1, + name: 'Domain One', + desc: 'Description one', + is_open: true, + max_realm: 'Level 5', + danger_prob: 0.3, + drop_prob: 0.5, + cd_years: 10, + open_prob: 0.1, + }, + { + id: 2, + name: 'Domain Two', + desc: 'Description two', + is_open: false, + max_realm: 'Level 3', + danger_prob: 0.2, + drop_prob: 0.4, + cd_years: 5, + open_prob: 0.2, + }, + ] + + it('should render list mode with items', () => { + const wrapper = mount(StatusWidget, { + props: { + ...defaultProps, + mode: 'list', + items: domainItems, + title: 'Domain List', + }, + }) + + expect(wrapper.find('.list-header').text()).toBe('Domain List') + expect(wrapper.findAll('.n-list-item-stub').length).toBe(2) + }) + + it('should show empty state when no items', () => { + const wrapper = mount(StatusWidget, { + props: { + ...defaultProps, + mode: 'list', + items: [], + emptyText: 'No data available', + }, + }) + + expect(wrapper.find('.n-empty-stub').exists()).toBe(true) + expect(wrapper.find('.n-empty-stub').text()).toBe('No data available') + }) + + it('should use default emptyText when not provided', () => { + const wrapper = mount(StatusWidget, { + props: { + ...defaultProps, + mode: 'list', + items: [], + }, + }) + + // Default is Chinese text from props defaults. + expect(wrapper.find('.n-empty-stub').exists()).toBe(true) + }) + + it('should render domain item details', () => { + const wrapper = mount(StatusWidget, { + props: { + ...defaultProps, + mode: 'list', + items: [domainItems[0]], + }, + }) + + expect(wrapper.text()).toContain('Domain One') + expect(wrapper.text()).toContain('Description one') + expect(wrapper.text()).toContain('30%') // danger_prob formatted. + expect(wrapper.text()).toContain('50%') // drop_prob formatted. + }) + + it('should apply is-closed class for closed domains', () => { + const wrapper = mount(StatusWidget, { + props: { + ...defaultProps, + mode: 'list', + items: [domainItems[1]], // is_open: false. + }, + }) + + expect(wrapper.find('.domain-item.is-closed').exists()).toBe(true) + }) + + it('should not apply is-closed class for open domains', () => { + const wrapper = mount(StatusWidget, { + props: { + ...defaultProps, + mode: 'list', + items: [domainItems[0]], // is_open: true. + }, + }) + + expect(wrapper.find('.domain-item.is-closed').exists()).toBe(false) + }) + }) + + describe('single mode', () => { + it('should render single mode slot', () => { + const wrapper = mount(StatusWidget, { + props: { + ...defaultProps, + mode: 'single', + }, + slots: { + single: '
Custom Content
', + }, + }) + + expect(wrapper.find('.custom-single').exists()).toBe(true) + expect(wrapper.text()).toContain('Custom Content') + }) + }) + + it('should render divider', () => { + const wrapper = mount(StatusWidget, { + props: defaultProps, + }) + + expect(wrapper.find('.divider').exists()).toBe(true) + expect(wrapper.find('.divider').text()).toBe('|') + }) + + it('should handle title in list mode', () => { + const wrapper = mount(StatusWidget, { + props: { + ...defaultProps, + mode: 'list', + title: 'Custom Title', + items: [{ id: 1, name: 'Test', desc: '', is_open: true, max_realm: '', danger_prob: 0, drop_prob: 0, cd_years: 0, open_prob: 0 }], + }, + }) + + expect(wrapper.find('.list-header').text()).toBe('Custom Title') + }) + + it('should hide title when not provided', () => { + const wrapper = mount(StatusWidget, { + props: { + ...defaultProps, + mode: 'list', + items: [], + }, + }) + + expect(wrapper.find('.list-header').exists()).toBe(false) + }) +}) diff --git a/web/src/__tests__/components/TagList.test.ts b/web/src/__tests__/components/TagList.test.ts new file mode 100644 index 0000000..ec35c14 --- /dev/null +++ b/web/src/__tests__/components/TagList.test.ts @@ -0,0 +1,163 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount } from '@vue/test-utils' + +// Mock getEntityColor. +const mockGetEntityColor = vi.fn() + +vi.mock('@/utils/theme', () => ({ + getEntityColor: (entity: any) => mockGetEntityColor(entity), +})) + +import TagList from '@/components/game/panels/info/components/TagList.vue' + +describe('TagList', () => { + const defaultTags = [ + { id: '1', name: 'Tag One' }, + { id: '2', name: 'Tag Two' }, + { id: '3', name: 'Tag Three' }, + ] + + beforeEach(() => { + vi.clearAllMocks() + mockGetEntityColor.mockReturnValue('#ff0000') + }) + + it('should render all tags', () => { + const wrapper = mount(TagList, { + props: { + tags: defaultTags, + }, + }) + + const tags = wrapper.findAll('.tag') + expect(tags.length).toBe(3) + expect(tags[0].text()).toBe('Tag One') + expect(tags[1].text()).toBe('Tag Two') + expect(tags[2].text()).toBe('Tag Three') + }) + + it('should apply border color from getEntityColor', () => { + mockGetEntityColor.mockImplementation((tag) => { + if (tag.id === '1') return '#ff0000' + if (tag.id === '2') return '#00ff00' + return '#0000ff' + }) + + const wrapper = mount(TagList, { + props: { + tags: defaultTags, + }, + }) + + const tags = wrapper.findAll('.tag') + + expect(mockGetEntityColor).toHaveBeenCalledTimes(3) + expect(tags[0].attributes('style')).toContain('border-color: rgb(255, 0, 0)') + expect(tags[1].attributes('style')).toContain('border-color: rgb(0, 255, 0)') + expect(tags[2].attributes('style')).toContain('border-color: rgb(0, 0, 255)') + }) + + it('should emit click with tag item when tag is clicked', async () => { + const wrapper = mount(TagList, { + props: { + tags: defaultTags, + }, + }) + + const tags = wrapper.findAll('.tag') + await tags[1].trigger('click') + + expect(wrapper.emitted('click')).toBeTruthy() + expect(wrapper.emitted('click')?.length).toBe(1) + expect(wrapper.emitted('click')?.[0]).toEqual([defaultTags[1]]) + }) + + it('should emit click with correct tag on each click', async () => { + const wrapper = mount(TagList, { + props: { + tags: defaultTags, + }, + }) + + const tags = wrapper.findAll('.tag') + await tags[0].trigger('click') + await tags[2].trigger('click') + + expect(wrapper.emitted('click')?.length).toBe(2) + expect(wrapper.emitted('click')?.[0]).toEqual([defaultTags[0]]) + expect(wrapper.emitted('click')?.[1]).toEqual([defaultTags[2]]) + }) + + it('should handle empty tags array', () => { + const wrapper = mount(TagList, { + props: { + tags: [], + }, + }) + + const tags = wrapper.findAll('.tag') + expect(tags.length).toBe(0) + expect(wrapper.find('.tags-container').exists()).toBe(true) + }) + + it('should use tag name as key when id is not available', () => { + const tagsWithoutId = [ + { name: 'No ID Tag 1' }, + { name: 'No ID Tag 2' }, + ] + + const wrapper = mount(TagList, { + props: { + tags: tagsWithoutId, + }, + }) + + const tags = wrapper.findAll('.tag') + expect(tags.length).toBe(2) + expect(tags[0].text()).toBe('No ID Tag 1') + expect(tags[1].text()).toBe('No ID Tag 2') + }) + + it('should handle single tag', () => { + const singleTag = [{ id: '1', name: 'Only Tag' }] + + const wrapper = mount(TagList, { + props: { + tags: singleTag, + }, + }) + + const tags = wrapper.findAll('.tag') + expect(tags.length).toBe(1) + expect(tags[0].text()).toBe('Only Tag') + }) + + it('should handle undefined color gracefully', () => { + mockGetEntityColor.mockReturnValue(undefined) + + const wrapper = mount(TagList, { + props: { + tags: defaultTags, + }, + }) + + // Should not throw, tags should still render. + const tags = wrapper.findAll('.tag') + expect(tags.length).toBe(3) + }) + + it('should handle tags with additional properties', () => { + const tagsWithExtra = [ + { id: '1', name: 'Tag', grade: 'SSR', rarity: 'epic' }, + ] + + const wrapper = mount(TagList, { + props: { + tags: tagsWithExtra, + }, + }) + + expect(wrapper.find('.tag').text()).toBe('Tag') + expect(mockGetEntityColor).toHaveBeenCalledWith(tagsWithExtra[0]) + }) +}) diff --git a/web/src/__tests__/composables/useGameInit.test.ts b/web/src/__tests__/composables/useGameInit.test.ts index a25b50e..c717c75 100644 --- a/web/src/__tests__/composables/useGameInit.test.ts +++ b/web/src/__tests__/composables/useGameInit.test.ts @@ -144,4 +144,254 @@ describe('useGameInit', () => { wrapper.unmount() }) }) + + describe('pollInitStatus', () => { + it('should call onIdle callback when status changes to idle', async () => { + const onIdleMock = vi.fn() + + // First call returns non-idle, second call returns idle. + vi.spyOn(systemStore, 'fetchInitStatus') + .mockResolvedValueOnce(createMockStatus({ status: 'initializing' })) + .mockResolvedValueOnce(createMockStatus({ status: 'idle' })) + + const TestComponent = createTestComponent({ onIdle: onIdleMock }) + const wrapper = mount(TestComponent) + + // First poll - initializing. + await vi.advanceTimersByTimeAsync(0) + await nextTick() + + // Second poll - idle. + await vi.advanceTimersByTimeAsync(1000) + await nextTick() + + expect(onIdleMock).toHaveBeenCalled() + + wrapper.unmount() + }) + + it('should reset world and system when returning to idle from initialized state', async () => { + const resetSpy = vi.spyOn(worldStore, 'reset') + const setInitializedSpy = vi.spyOn(systemStore, 'setInitialized') + + // Simulate already initialized. + systemStore.setInitialized(true) + + vi.spyOn(systemStore, 'fetchInitStatus') + .mockResolvedValue(createMockStatus({ status: 'idle' })) + + const TestComponent = createTestComponent() + const wrapper = mount(TestComponent) + + await vi.advanceTimersByTimeAsync(0) + await nextTick() + + expect(setInitializedSpy).toHaveBeenCalledWith(false) + expect(resetSpy).toHaveBeenCalled() + + wrapper.unmount() + }) + + it('should preload map when phase is in MAP_READY', async () => { + const preloadMapSpy = vi.spyOn(worldStore, 'preloadMap') + + vi.spyOn(systemStore, 'fetchInitStatus') + .mockResolvedValue(createMockStatus({ + status: 'initializing', + phase_name: 'initializing_sects' // This is in MAP_READY. + })) + + const TestComponent = createTestComponent() + const wrapper = mount(TestComponent) + + await vi.advanceTimersByTimeAsync(0) + await nextTick() + + expect(preloadMapSpy).toHaveBeenCalled() + expect(wrapper.vm.mapPreloaded).toBe(true) + + wrapper.unmount() + }) + + it('should not preload map twice', async () => { + const preloadMapSpy = vi.spyOn(worldStore, 'preloadMap') + + vi.spyOn(systemStore, 'fetchInitStatus') + .mockResolvedValue(createMockStatus({ + status: 'initializing', + phase_name: 'initializing_sects' + })) + + const TestComponent = createTestComponent() + const wrapper = mount(TestComponent) + + // First poll. + await vi.advanceTimersByTimeAsync(0) + await nextTick() + + // Second poll. + await vi.advanceTimersByTimeAsync(1000) + await nextTick() + + // Should only be called once. + expect(preloadMapSpy).toHaveBeenCalledTimes(1) + + wrapper.unmount() + }) + + it('should preload avatars when phase is in AVATAR_READY', async () => { + const preloadAvatarsSpy = vi.spyOn(worldStore, 'preloadAvatars') + + vi.spyOn(systemStore, 'fetchInitStatus') + .mockResolvedValue(createMockStatus({ + status: 'initializing', + phase_name: 'checking_llm' // This is in AVATAR_READY. + })) + + const TestComponent = createTestComponent() + const wrapper = mount(TestComponent) + + await vi.advanceTimersByTimeAsync(0) + await nextTick() + + expect(preloadAvatarsSpy).toHaveBeenCalled() + expect(wrapper.vm.avatarsPreloaded).toBe(true) + + wrapper.unmount() + }) + + it('should initialize game when status transitions to ready', async () => { + const initializeSpy = vi.spyOn(worldStore, 'initialize').mockResolvedValue(undefined) + const setInitializedSpy = vi.spyOn(systemStore, 'setInitialized') + + // First call returns initializing, second call returns ready. + vi.spyOn(systemStore, 'fetchInitStatus') + .mockResolvedValueOnce(createMockStatus({ status: 'initializing' })) + .mockResolvedValueOnce(createMockStatus({ status: 'ready' })) + + const TestComponent = createTestComponent() + const wrapper = mount(TestComponent) + + // First poll - initializing. + await vi.advanceTimersByTimeAsync(0) + await nextTick() + + // Second poll - ready. + await vi.advanceTimersByTimeAsync(1000) + await nextTick() + + expect(initializeSpy).toHaveBeenCalled() + expect(setInitializedSpy).toHaveBeenCalledWith(true) + expect(mockLoadBaseTextures).toHaveBeenCalled() + + wrapper.unmount() + }) + + it('should handle fetch error gracefully', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + vi.spyOn(systemStore, 'fetchInitStatus') + .mockRejectedValue(new Error('Network error')) + + const TestComponent = createTestComponent() + const wrapper = mount(TestComponent) + + await vi.advanceTimersByTimeAsync(0) + await nextTick() + + expect(consoleSpy).toHaveBeenCalledWith('Failed to fetch init status:', expect.any(Error)) + + consoleSpy.mockRestore() + wrapper.unmount() + }) + + it('should return early if fetchInitStatus returns null', async () => { + const preloadMapSpy = vi.spyOn(worldStore, 'preloadMap') + + vi.spyOn(systemStore, 'fetchInitStatus') + .mockResolvedValue(null as any) + + const TestComponent = createTestComponent() + const wrapper = mount(TestComponent) + + await vi.advanceTimersByTimeAsync(0) + await nextTick() + + // Nothing should be called because res is null. + expect(preloadMapSpy).not.toHaveBeenCalled() + + wrapper.unmount() + }) + }) + + describe('initializeGame', () => { + it('should reset world when already initialized', async () => { + const resetSpy = vi.spyOn(worldStore, 'reset') + const initializeSpy = vi.spyOn(worldStore, 'initialize').mockResolvedValue(undefined) + + // Mark as initialized. + systemStore.setInitialized(true) + + vi.spyOn(systemStore, 'fetchInitStatus') + .mockResolvedValue(createMockStatus({ status: 'idle' })) + + const TestComponent = createTestComponent() + const wrapper = mount(TestComponent) + + await vi.advanceTimersByTimeAsync(0) + await nextTick() + + // Manually call initializeGame. + await wrapper.vm.initializeGame() + + expect(resetSpy).toHaveBeenCalled() + expect(initializeSpy).toHaveBeenCalled() + + wrapper.unmount() + }) + + it('should init socket if not connected', async () => { + const initSpy = vi.spyOn(socketStore, 'init') + const initializeSpy = vi.spyOn(worldStore, 'initialize').mockResolvedValue(undefined) + + vi.spyOn(systemStore, 'fetchInitStatus') + .mockResolvedValue(createMockStatus({ status: 'idle' })) + + const TestComponent = createTestComponent() + const wrapper = mount(TestComponent) + + await vi.advanceTimersByTimeAsync(0) + await nextTick() + + // Ensure socket is not connected. + expect(socketStore.isConnected).toBe(false) + + await wrapper.vm.initializeGame() + + expect(initSpy).toHaveBeenCalled() + + wrapper.unmount() + }) + }) + + describe('stopPolling', () => { + it('should clear interval when called', async () => { + const TestComponent = createTestComponent() + const wrapper = mount(TestComponent) + + await vi.advanceTimersByTimeAsync(0) + await nextTick() + + // Stop polling. + wrapper.vm.stopPolling() + + // Verify no more polls happen. + vi.clearAllMocks() + await vi.advanceTimersByTimeAsync(2000) + + expect(systemStore.fetchInitStatus).not.toHaveBeenCalled() + + wrapper.unmount() + }) + }) }) diff --git a/web/src/__tests__/stores/setting.test.ts b/web/src/__tests__/stores/setting.test.ts new file mode 100644 index 0000000..79b0e5a --- /dev/null +++ b/web/src/__tests__/stores/setting.test.ts @@ -0,0 +1,161 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' + +// Use vi.hoisted to define mock functions that will be used by vi.mock. +const { mockSetLanguage } = vi.hoisted(() => ({ + mockSetLanguage: vi.fn().mockResolvedValue(undefined), +})) + +// Create a fresh mock i18n object for each test. +let mockI18nLocale: { value: string } | string = { value: 'zh-CN' } +let mockI18nMode = 'composition' + +// Mock i18n. +vi.mock('@/locales', () => ({ + default: { + get mode() { return mockI18nMode }, + global: { + get locale() { return mockI18nLocale }, + set locale(val) { mockI18nLocale = val }, + }, + }, +})) + +// Mock systemApi. +vi.mock('@/api/modules/system', () => ({ + systemApi: { + setLanguage: mockSetLanguage, + }, +})) + +import { useSettingStore } from '@/stores/setting' + +describe('useSettingStore', () => { + let store: ReturnType + let localStorageMock: Record + + beforeEach(() => { + // Reset localStorage mock. + localStorageMock = {} + vi.spyOn(Storage.prototype, 'getItem').mockImplementation((key: string) => { + return localStorageMock[key] || null + }) + vi.spyOn(Storage.prototype, 'setItem').mockImplementation((key: string, value: string) => { + localStorageMock[key] = value + }) + + // Reset mocks. + vi.clearAllMocks() + mockI18nLocale = { value: 'zh-CN' } + mockI18nMode = 'composition' + + setActivePinia(createPinia()) + store = useSettingStore() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe('initial state', () => { + it('should read locale from localStorage if available', () => { + // Reset with new localStorage value. + localStorageMock = { app_locale: 'en-US' } + setActivePinia(createPinia()) + const newStore = useSettingStore() + + expect(newStore.locale).toBe('en-US') + }) + + it('should default to zh-CN if localStorage is empty', () => { + expect(store.locale).toBe('zh-CN') + }) + }) + + describe('setLocale', () => { + it('should update locale value', async () => { + await store.setLocale('en-US') + + expect(store.locale).toBe('en-US') + }) + + it('should save locale to localStorage', async () => { + await store.setLocale('zh-TW') + + expect(localStorageMock.app_locale).toBe('zh-TW') + }) + + it('should update i18n.global.locale in composition mode', async () => { + mockI18nMode = 'composition' + mockI18nLocale = { value: 'zh-CN' } + + await store.setLocale('en-US') + + expect((mockI18nLocale as { value: string }).value).toBe('en-US') + }) + + it('should update i18n.global.locale in legacy mode', async () => { + mockI18nMode = 'legacy' + mockI18nLocale = 'zh-CN' + + await store.setLocale('en-US') + + expect(mockI18nLocale).toBe('en-US') + }) + + it('should update document.documentElement.lang', async () => { + await store.setLocale('en-US') + + expect(document.documentElement.lang).toBe('en') + }) + + it('should set correct HTML lang for zh-CN', async () => { + await store.setLocale('zh-CN') + + expect(document.documentElement.lang).toBe('zh-CN') + }) + + it('should set correct HTML lang for zh-TW', async () => { + await store.setLocale('zh-TW') + + expect(document.documentElement.lang).toBe('zh-TW') + }) + + it('should call syncBackend after updating locale', async () => { + await store.setLocale('en-US') + + expect(mockSetLanguage).toHaveBeenCalledWith('en-US') + }) + }) + + describe('syncBackend', () => { + it('should call systemApi.setLanguage with current locale', async () => { + store.locale = 'zh-TW' + + await store.syncBackend() + + expect(mockSetLanguage).toHaveBeenCalledWith('zh-TW') + }) + + it('should catch errors and log warning', async () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + mockSetLanguage.mockRejectedValueOnce(new Error('Network error')) + + await store.syncBackend() + + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to sync language with backend:', + expect.any(Error) + ) + + consoleSpy.mockRestore() + }) + + it('should not throw when API fails', async () => { + mockSetLanguage.mockRejectedValueOnce(new Error('API error')) + + // Should not throw. + await expect(store.syncBackend()).resolves.not.toThrow() + }) + }) +}) diff --git a/web/src/__tests__/stores/socket.test.ts b/web/src/__tests__/stores/socket.test.ts index 4bf0592..b9da2c8 100644 --- a/web/src/__tests__/stores/socket.test.ts +++ b/web/src/__tests__/stores/socket.test.ts @@ -1,11 +1,35 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { setActivePinia, createPinia } from 'pinia' -// Create mock functions at module level. -const mockOn = vi.fn(() => vi.fn()) -const mockOnStatusChange = vi.fn(() => vi.fn()) -const mockConnect = vi.fn() -const mockDisconnect = vi.fn() +// Use vi.hoisted to define mocks before vi.mock is hoisted. +const { + mockOn, + mockOnStatusChange, + mockConnect, + mockDisconnect, + mockWorldStore, + mockUiStore, + mockMessage, +} = vi.hoisted(() => ({ + mockOn: vi.fn(() => vi.fn()), + mockOnStatusChange: vi.fn(() => vi.fn()), + mockConnect: vi.fn(), + mockDisconnect: vi.fn(), + mockWorldStore: { + handleTick: vi.fn(), + initialize: vi.fn().mockResolvedValue(undefined), + }, + mockUiStore: { + selectedTarget: null as { type: string; id: string } | null, + refreshDetail: vi.fn(), + }, + mockMessage: { + error: vi.fn(), + warning: vi.fn(), + success: vi.fn(), + info: vi.fn(), + }, +})) // Store callbacks for testing message handling. let messageCallback: ((data: any) => void) | null = null @@ -27,16 +51,23 @@ vi.mock('@/api/socket', () => ({ }, })) -// Mock world and ui stores. -const mockWorldStore = { - handleTick: vi.fn(), - initialize: vi.fn().mockResolvedValue(undefined), -} +vi.mock('@/utils/discreteApi', () => ({ + message: mockMessage, +})) -const mockUiStore = { - selectedTarget: null as { type: string; id: string } | null, - refreshDetail: vi.fn(), -} +// Mock i18n with mutable module-level variables. +let mockI18nMode = 'composition' +let mockI18nLocale: any = { value: 'zh-CN' } + +vi.mock('@/locales', () => ({ + default: { + get mode() { return mockI18nMode }, + global: { + get locale() { return mockI18nLocale }, + set locale(val) { mockI18nLocale = val }, + }, + }, +})) vi.mock('@/stores/world', () => ({ useWorldStore: () => mockWorldStore, @@ -54,7 +85,7 @@ describe('useSocketStore', () => { beforeEach(() => { setActivePinia(createPinia()) store = useSocketStore() - + // Reset mocks and callbacks. vi.clearAllMocks() mockUiStore.selectedTarget = null @@ -62,6 +93,10 @@ describe('useSocketStore', () => { mockOnStatusChange.mockReturnValue(vi.fn()) messageCallback = null statusCallback = null + + // Reset i18n mock. + mockI18nMode = 'composition' + mockI18nLocale = { value: 'zh-CN' } }) afterEach(() => { @@ -215,6 +250,186 @@ describe('useSocketStore', () => { }) }) + describe('toast message handling', () => { + it('should show error toast for error level', () => { + store.init() + + messageCallback?.({ type: 'toast', level: 'error', message: 'Error message' }) + + expect(mockMessage.error).toHaveBeenCalledWith('Error message') + }) + + it('should show warning toast for warning level', () => { + store.init() + + messageCallback?.({ type: 'toast', level: 'warning', message: 'Warning message' }) + + expect(mockMessage.warning).toHaveBeenCalledWith('Warning message') + }) + + it('should show success toast for success level', () => { + store.init() + + messageCallback?.({ type: 'toast', level: 'success', message: 'Success message' }) + + expect(mockMessage.success).toHaveBeenCalledWith('Success message') + }) + + it('should show info toast for info level', () => { + store.init() + + messageCallback?.({ type: 'toast', level: 'info', message: 'Info message' }) + + expect(mockMessage.info).toHaveBeenCalledWith('Info message') + }) + + it('should show info toast for unknown level', () => { + store.init() + + messageCallback?.({ type: 'toast', level: 'unknown', message: 'Unknown level message' }) + + expect(mockMessage.info).toHaveBeenCalledWith('Unknown level message') + }) + + it('should switch language in composition mode when language field is present', () => { + mockI18nMode = 'composition' + mockI18nLocale = { value: 'zh-CN' } + const localStorageSpy = vi.spyOn(Storage.prototype, 'setItem') + + store.init() + messageCallback?.({ type: 'toast', level: 'info', message: 'Test', language: 'en-US' }) + + expect(mockI18nLocale.value).toBe('en-US') + expect(localStorageSpy).toHaveBeenCalledWith('app_locale', 'en-US') + localStorageSpy.mockRestore() + }) + + it('should switch language in legacy mode when language field is present', () => { + mockI18nMode = 'legacy' + mockI18nLocale = 'zh-CN' + const localStorageSpy = vi.spyOn(Storage.prototype, 'setItem') + + store.init() + messageCallback?.({ type: 'toast', level: 'info', message: 'Test', language: 'en-US' }) + + expect(mockI18nLocale).toBe('en-US') + expect(localStorageSpy).toHaveBeenCalledWith('app_locale', 'en-US') + localStorageSpy.mockRestore() + }) + + it('should not switch language if same as current', () => { + mockI18nMode = 'composition' + mockI18nLocale = { value: 'en-US' } + const localStorageSpy = vi.spyOn(Storage.prototype, 'setItem') + + store.init() + messageCallback?.({ type: 'toast', level: 'info', message: 'Test', language: 'en-US' }) + + // Should not call setItem if language is same. + expect(localStorageSpy).not.toHaveBeenCalled() + localStorageSpy.mockRestore() + }) + + it('should update document.documentElement.lang for zh-CN', () => { + mockI18nMode = 'composition' + mockI18nLocale = { value: 'en-US' } + + store.init() + messageCallback?.({ type: 'toast', level: 'info', message: 'Test', language: 'zh-CN' }) + + expect(document.documentElement.lang).toBe('zh-CN') + }) + + it('should update document.documentElement.lang for en-US', () => { + mockI18nMode = 'composition' + mockI18nLocale = { value: 'zh-CN' } + + store.init() + messageCallback?.({ type: 'toast', level: 'info', message: 'Test', language: 'en-US' }) + + expect(document.documentElement.lang).toBe('en') + }) + + it('should handle language switch errors gracefully', () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + // Create a getter that throws. + mockI18nMode = 'composition' + const errorObj = { + get value() { throw new Error('Test error') }, + set value(_v) { throw new Error('Test error') }, + } + mockI18nLocale = errorObj + + store.init() + // Should not throw. + messageCallback?.({ type: 'toast', level: 'info', message: 'Test', language: 'en-US' }) + + expect(consoleSpy).toHaveBeenCalledWith('[Socket] Failed to switch language:', expect.any(Error)) + consoleSpy.mockRestore() + }) + }) + + describe('llm_config_required alert handling', () => { + it('should call alert after timeout for llm_config_required', async () => { + const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {}) + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + store.init() + messageCallback?.({ type: 'llm_config_required', error: 'LLM error' }) + + // Run the setTimeout. + await vi.advanceTimersByTimeAsync(500) + + expect(alertSpy).toHaveBeenCalled() + alertSpy.mockRestore() + consoleSpy.mockRestore() + }) + + it('should use default message when error is not provided', async () => { + const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {}) + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + store.init() + messageCallback?.({ type: 'llm_config_required' }) + + await vi.advanceTimersByTimeAsync(500) + + expect(alertSpy).toHaveBeenCalledWith(expect.stringContaining('LLM 连接失败')) + alertSpy.mockRestore() + consoleSpy.mockRestore() + }) + }) + + describe('game_reinitialized alert handling', () => { + it('should call alert after timeout for game_reinitialized', async () => { + const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {}) + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + store.init() + messageCallback?.({ type: 'game_reinitialized', message: 'Game restarted' }) + + await vi.advanceTimersByTimeAsync(300) + + expect(alertSpy).toHaveBeenCalledWith('Game restarted') + alertSpy.mockRestore() + consoleSpy.mockRestore() + }) + + it('should use default message when message is not provided', async () => { + const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {}) + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + store.init() + messageCallback?.({ type: 'game_reinitialized' }) + + await vi.advanceTimersByTimeAsync(300) + + expect(alertSpy).toHaveBeenCalledWith('LLM 配置成功,游戏已重新初始化') + alertSpy.mockRestore() + consoleSpy.mockRestore() + }) + }) + describe('status change handling', () => { it('should update isConnected when status changes to connected', () => { store.init() diff --git a/web/vitest.config.ts b/web/vitest.config.ts index 7b0f5e8..7c76249 100644 --- a/web/vitest.config.ts +++ b/web/vitest.config.ts @@ -16,6 +16,8 @@ export default defineConfig({ 'node_modules/', 'src/__tests__/', '**/*.d.ts', + 'vite.config.ts', + 'vitest.config.ts', ], }, },