From 665d94addb2446c13d984c6722abfe698a0ffcc0 Mon Sep 17 00:00:00 2001 From: Zihao Xu Date: Mon, 19 Jan 2026 04:48:43 -0800 Subject: [PATCH] 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 --- web/src/__tests__/stores/system.test.ts | 108 +++++++++++ web/src/__tests__/utils/eventHelper.test.ts | 194 ++++++++++++++++++++ 2 files changed, 302 insertions(+) create mode 100644 web/src/__tests__/stores/system.test.ts create mode 100644 web/src/__tests__/utils/eventHelper.test.ts diff --git a/web/src/__tests__/stores/system.test.ts b/web/src/__tests__/stores/system.test.ts new file mode 100644 index 0000000..bf559a5 --- /dev/null +++ b/web/src/__tests__/stores/system.test.ts @@ -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 + + 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) + }) + }) +}) diff --git a/web/src/__tests__/utils/eventHelper.test.ts b/web/src/__tests__/utils/eventHelper.test.ts new file mode 100644 index 0000000..503566d --- /dev/null +++ b/web/src/__tests__/utils/eventHelper.test.ts @@ -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(' { + const colorMap = new Map([['