test(web): add example frontend tests (#61)
* test(web): setup frontend test infrastructure - Add testing dependencies (vitest, vue-test-utils, testing-library, jsdom, msw) - Create vitest.config.ts with jsdom environment and coverage settings - Create setup.ts with Pinia initialization and fake timers - Add test scripts to package.json (test, test:run, test:coverage) Closes #56, closes #57 * test(web): add example frontend tests - Add system store tests (initial state, getters, togglePause) - Add eventHelper utility tests (processNewEvents, mergeAndSortEvents, avatarIdToColor, highlightAvatarNames) Closes #59
This commit is contained in:
108
web/src/__tests__/stores/system.test.ts
Normal file
108
web/src/__tests__/stores/system.test.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { useSystemStore } from '@/stores/system'
|
||||
|
||||
// Mock the API module.
|
||||
vi.mock('@/api', () => ({
|
||||
systemApi: {
|
||||
fetchInitStatus: vi.fn(),
|
||||
pauseGame: vi.fn(),
|
||||
resumeGame: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import { systemApi } from '@/api'
|
||||
|
||||
describe('useSystemStore', () => {
|
||||
let store: ReturnType<typeof useSystemStore>
|
||||
|
||||
beforeEach(() => {
|
||||
store = useSystemStore()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('initial state', () => {
|
||||
it('should have correct initial values', () => {
|
||||
expect(store.initStatus).toBeNull()
|
||||
expect(store.isInitialized).toBe(false)
|
||||
expect(store.isManualPaused).toBe(true)
|
||||
expect(store.isGameRunning).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isLoading', () => {
|
||||
it('should return true when initStatus is null', () => {
|
||||
expect(store.isLoading).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false when status is idle', () => {
|
||||
store.initStatus = { status: 'idle', progress: 0 }
|
||||
expect(store.isLoading).toBe(false)
|
||||
})
|
||||
|
||||
it('should return true when status is in_progress', () => {
|
||||
store.initStatus = { status: 'in_progress', progress: 50 }
|
||||
expect(store.isLoading).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false when status is ready and initialized', () => {
|
||||
store.initStatus = { status: 'ready', progress: 100 }
|
||||
store.setInitialized(true)
|
||||
expect(store.isLoading).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isReady', () => {
|
||||
it('should return false when not initialized', () => {
|
||||
store.initStatus = { status: 'ready', progress: 100 }
|
||||
expect(store.isReady).toBe(false)
|
||||
})
|
||||
|
||||
it('should return true when status is ready and initialized', () => {
|
||||
store.initStatus = { status: 'ready', progress: 100 }
|
||||
store.setInitialized(true)
|
||||
expect(store.isReady).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('togglePause', () => {
|
||||
it('should toggle from paused to playing and call resumeGame', async () => {
|
||||
store.isManualPaused = true
|
||||
vi.mocked(systemApi.resumeGame).mockResolvedValue(undefined)
|
||||
|
||||
await store.togglePause()
|
||||
|
||||
expect(store.isManualPaused).toBe(false)
|
||||
expect(systemApi.resumeGame).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should toggle from playing to paused and call pauseGame', async () => {
|
||||
store.isManualPaused = false
|
||||
vi.mocked(systemApi.pauseGame).mockResolvedValue(undefined)
|
||||
|
||||
await store.togglePause()
|
||||
|
||||
expect(store.isManualPaused).toBe(true)
|
||||
expect(systemApi.pauseGame).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should rollback state on API failure', async () => {
|
||||
store.isManualPaused = true
|
||||
vi.mocked(systemApi.resumeGame).mockRejectedValue(new Error('API error'))
|
||||
|
||||
await store.togglePause()
|
||||
|
||||
// Should rollback to original state.
|
||||
expect(store.isManualPaused).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setInitialized', () => {
|
||||
it('should set isInitialized value', () => {
|
||||
store.setInitialized(true)
|
||||
expect(store.isInitialized).toBe(true)
|
||||
|
||||
store.setInitialized(false)
|
||||
expect(store.isInitialized).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
194
web/src/__tests__/utils/eventHelper.test.ts
Normal file
194
web/src/__tests__/utils/eventHelper.test.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
processNewEvents,
|
||||
mergeAndSortEvents,
|
||||
avatarIdToColor,
|
||||
buildAvatarColorMap,
|
||||
highlightAvatarNames,
|
||||
MAX_EVENTS,
|
||||
} from '@/utils/eventHelper'
|
||||
import type { GameEvent } from '@/types/core'
|
||||
|
||||
describe('eventHelper', () => {
|
||||
describe('processNewEvents', () => {
|
||||
it('should return empty array for empty input', () => {
|
||||
expect(processNewEvents([], 100, 1)).toEqual([])
|
||||
expect(processNewEvents(null as any, 100, 1)).toEqual([])
|
||||
})
|
||||
|
||||
it('should process raw events with default year/month', () => {
|
||||
const rawEvents = [
|
||||
{ id: '1', text: 'Event 1' },
|
||||
{ id: '2', text: 'Event 2' },
|
||||
]
|
||||
|
||||
const result = processNewEvents(rawEvents, 100, 5)
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0]).toMatchObject({
|
||||
id: '1',
|
||||
text: 'Event 1',
|
||||
year: 100,
|
||||
month: 5,
|
||||
timestamp: 100 * 12 + 5,
|
||||
_seq: 0,
|
||||
})
|
||||
expect(result[1]._seq).toBe(1)
|
||||
})
|
||||
|
||||
it('should use event year/month when provided', () => {
|
||||
const rawEvents = [{ id: '1', year: 50, month: 3 }]
|
||||
|
||||
const result = processNewEvents(rawEvents, 100, 5)
|
||||
|
||||
expect(result[0].year).toBe(50)
|
||||
expect(result[0].month).toBe(3)
|
||||
expect(result[0].timestamp).toBe(50 * 12 + 3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mergeAndSortEvents', () => {
|
||||
const createEvent = (id: string, timestamp: number, createdAt?: number): GameEvent => ({
|
||||
id,
|
||||
timestamp,
|
||||
createdAt,
|
||||
year: Math.floor(timestamp / 12),
|
||||
month: timestamp % 12,
|
||||
text: `Event ${id}`,
|
||||
relatedAvatarIds: [],
|
||||
})
|
||||
|
||||
it('should merge events without duplicates', () => {
|
||||
const existing = [createEvent('1', 100), createEvent('2', 101)]
|
||||
const newEvents = [createEvent('2', 101), createEvent('3', 102)]
|
||||
|
||||
const result = mergeAndSortEvents(existing, newEvents)
|
||||
|
||||
expect(result).toHaveLength(3)
|
||||
expect(result.map(e => e.id)).toEqual(['1', '2', '3'])
|
||||
})
|
||||
|
||||
it('should sort by timestamp ascending', () => {
|
||||
const existing = [createEvent('3', 300)]
|
||||
const newEvents = [createEvent('1', 100), createEvent('2', 200)]
|
||||
|
||||
const result = mergeAndSortEvents(existing, newEvents)
|
||||
|
||||
expect(result.map(e => e.id)).toEqual(['1', '2', '3'])
|
||||
})
|
||||
|
||||
it('should sort by createdAt when timestamps are equal', () => {
|
||||
const existing: GameEvent[] = []
|
||||
const newEvents = [
|
||||
createEvent('2', 100, 2000),
|
||||
createEvent('1', 100, 1000),
|
||||
]
|
||||
|
||||
const result = mergeAndSortEvents(existing, newEvents)
|
||||
|
||||
expect(result.map(e => e.id)).toEqual(['1', '2'])
|
||||
})
|
||||
|
||||
it('should truncate to MAX_EVENTS', () => {
|
||||
const events = Array.from({ length: MAX_EVENTS + 50 }, (_, i) =>
|
||||
createEvent(`${i}`, i)
|
||||
)
|
||||
|
||||
const result = mergeAndSortEvents([], events)
|
||||
|
||||
expect(result).toHaveLength(MAX_EVENTS)
|
||||
// Should keep the latest events.
|
||||
expect(result[0].id).toBe('50')
|
||||
})
|
||||
})
|
||||
|
||||
describe('avatarIdToColor', () => {
|
||||
it('should return consistent color for same id', () => {
|
||||
const color1 = avatarIdToColor('avatar-123')
|
||||
const color2 = avatarIdToColor('avatar-123')
|
||||
expect(color1).toBe(color2)
|
||||
})
|
||||
|
||||
it('should return different colors for different ids', () => {
|
||||
const color1 = avatarIdToColor('avatar-123')
|
||||
const color2 = avatarIdToColor('avatar-456')
|
||||
expect(color1).not.toBe(color2)
|
||||
})
|
||||
|
||||
it('should return valid HSL color', () => {
|
||||
const color = avatarIdToColor('test-id')
|
||||
expect(color).toMatch(/^hsl\(\d+, 70%, 65%\)$/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildAvatarColorMap', () => {
|
||||
it('should build map from avatar list', () => {
|
||||
const avatars = [
|
||||
{ id: '1', name: 'Alice' },
|
||||
{ id: '2', name: 'Bob' },
|
||||
]
|
||||
|
||||
const map = buildAvatarColorMap(avatars)
|
||||
|
||||
expect(map.size).toBe(2)
|
||||
expect(map.has('Alice')).toBe(true)
|
||||
expect(map.has('Bob')).toBe(true)
|
||||
})
|
||||
|
||||
it('should skip avatars without name', () => {
|
||||
const avatars = [
|
||||
{ id: '1', name: 'Alice' },
|
||||
{ id: '2' }, // No name.
|
||||
]
|
||||
|
||||
const map = buildAvatarColorMap(avatars)
|
||||
|
||||
expect(map.size).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('highlightAvatarNames', () => {
|
||||
it('should return original text when colorMap is empty', () => {
|
||||
const text = 'Hello World'
|
||||
const result = highlightAvatarNames(text, new Map())
|
||||
expect(result).toBe(text)
|
||||
})
|
||||
|
||||
it('should highlight avatar names with color spans', () => {
|
||||
const colorMap = new Map([['Alice', 'hsl(100, 70%, 65%)']])
|
||||
const text = 'Alice defeated the enemy'
|
||||
|
||||
const result = highlightAvatarNames(text, colorMap)
|
||||
|
||||
expect(result).toContain('<span')
|
||||
expect(result).toContain('Alice')
|
||||
expect(result).toContain('hsl(100, 70%, 65%)')
|
||||
})
|
||||
|
||||
it('should escape HTML in names', () => {
|
||||
const colorMap = new Map([['<script>', 'hsl(0, 70%, 65%)']])
|
||||
const text = 'User <script> logged in'
|
||||
|
||||
const result = highlightAvatarNames(text, colorMap)
|
||||
|
||||
expect(result).not.toContain('<script>')
|
||||
expect(result).toContain('<script>')
|
||||
})
|
||||
|
||||
it('should match longer names first to avoid partial matches', () => {
|
||||
const colorMap = new Map([
|
||||
['张三', 'hsl(100, 70%, 65%)'],
|
||||
['张三丰', 'hsl(200, 70%, 65%)'],
|
||||
])
|
||||
const text = '张三丰是一位大师'
|
||||
|
||||
const result = highlightAvatarNames(text, colorMap)
|
||||
|
||||
// Should match 张三丰, not 张三.
|
||||
expect(result).toContain('hsl(200, 70%, 65%)')
|
||||
// 张三 should not be separately highlighted within 张三丰.
|
||||
const matches = result.match(/hsl\(100/g)
|
||||
expect(matches).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user