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)
This commit is contained in:
Zihao Xu
2026-02-04 01:01:58 -08:00
committed by GitHub
parent 51472c0580
commit f15ee94559
10 changed files with 1914 additions and 15 deletions

View File

@@ -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)
})
})

View File

@@ -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')
})
})

View File

@@ -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')
})
})

View File

@@ -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')
})
})

View File

@@ -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: '<div class="custom-single">Custom Content</div>',
},
})
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)
})
})

View File

@@ -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])
})
})

View File

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

View File

@@ -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<typeof useSettingStore>
let localStorageMock: Record<string, string>
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()
})
})
})

View File

@@ -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,
@@ -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()

View File

@@ -16,6 +16,8 @@ export default defineConfig({
'node_modules/',
'src/__tests__/',
'**/*.d.ts',
'vite.config.ts',
'vitest.config.ts',
],
},
},