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:
Zihao Xu
2026-01-19 04:48:43 -08:00
committed by GitHub
parent 8bf5f64bc3
commit 665d94addb
2 changed files with 302 additions and 0 deletions

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

View 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('&lt;script&gt;')
})
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()
})
})
})