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: '