From 406d62f9834e4348f1081a7b612884544f7ee402 Mon Sep 17 00:00:00 2001 From: Zihao Xu Date: Sun, 25 Jan 2026 18:01:38 -0800 Subject: [PATCH] test(web): add tests for utility functions (#102) Add tests for utility modules: - formatters/number.ts: formatHp, formatAge - mapStyles.ts: REGION_STYLES, getRegionTextStyle - procedural.ts: getClusteredTileVariant - theme.ts: GRADE_COLORS, getEntityColor Total: 52 new tests added Closes #87 --- .../__tests__/utils/formatters/number.test.ts | 53 +++++++ web/src/__tests__/utils/mapStyles.test.ts | 64 ++++++++ web/src/__tests__/utils/procedural.test.ts | 116 ++++++++++++++ web/src/__tests__/utils/theme.test.ts | 146 ++++++++++++++++++ 4 files changed, 379 insertions(+) create mode 100644 web/src/__tests__/utils/formatters/number.test.ts create mode 100644 web/src/__tests__/utils/mapStyles.test.ts create mode 100644 web/src/__tests__/utils/procedural.test.ts create mode 100644 web/src/__tests__/utils/theme.test.ts diff --git a/web/src/__tests__/utils/formatters/number.test.ts b/web/src/__tests__/utils/formatters/number.test.ts new file mode 100644 index 0000000..f8be26b --- /dev/null +++ b/web/src/__tests__/utils/formatters/number.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from 'vitest' +import { formatHp, formatAge } from '@/utils/formatters/number' + +describe('formatters/number', () => { + describe('formatHp', () => { + it('should format integer HP values', () => { + expect(formatHp(100, 200)).toBe('100 / 200') + }) + + it('should floor decimal current HP', () => { + expect(formatHp(99.7, 200)).toBe('99 / 200') + expect(formatHp(99.2, 200)).toBe('99 / 200') + }) + + it('should handle full HP', () => { + expect(formatHp(100, 100)).toBe('100 / 100') + }) + + it('should handle zero HP', () => { + expect(formatHp(0, 100)).toBe('0 / 100') + }) + + it('should handle large HP values', () => { + expect(formatHp(12345, 99999)).toBe('12345 / 99999') + }) + + it('should handle negative HP', () => { + expect(formatHp(-10, 100)).toBe('-10 / 100') + }) + }) + + describe('formatAge', () => { + it('should format age and lifespan', () => { + expect(formatAge(25, 100)).toBe('25 / 100') + }) + + it('should handle zero age', () => { + expect(formatAge(0, 100)).toBe('0 / 100') + }) + + it('should handle age equal to lifespan', () => { + expect(formatAge(100, 100)).toBe('100 / 100') + }) + + it('should handle large age values', () => { + expect(formatAge(1000, 5000)).toBe('1000 / 5000') + }) + + it('should handle age exceeding lifespan', () => { + expect(formatAge(150, 100)).toBe('150 / 100') + }) + }) +}) diff --git a/web/src/__tests__/utils/mapStyles.test.ts b/web/src/__tests__/utils/mapStyles.test.ts new file mode 100644 index 0000000..4dbe9f6 --- /dev/null +++ b/web/src/__tests__/utils/mapStyles.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect } from 'vitest' +import { REGION_STYLES, getRegionTextStyle } from '@/utils/mapStyles' + +describe('mapStyles', () => { + describe('REGION_STYLES', () => { + it('should have sect style', () => { + expect(REGION_STYLES.sect).toBeDefined() + expect(REGION_STYLES.sect.fontSize).toBe(60) + expect(REGION_STYLES.sect.fill).toBe('#ffcc00') + }) + + it('should have city style', () => { + expect(REGION_STYLES.city).toBeDefined() + expect(REGION_STYLES.city.fontSize).toBe(72) + expect(REGION_STYLES.city.fill).toBe('#ccffcc') + }) + + it('should have default style', () => { + expect(REGION_STYLES.default).toBeDefined() + expect(REGION_STYLES.default.fontSize).toBe(72) + expect(REGION_STYLES.default.fill).toBe('#ffffff') + }) + + it('should have consistent font family', () => { + const expectedFont = '"Microsoft YaHei", sans-serif' + expect(REGION_STYLES.sect.fontFamily).toBe(expectedFont) + expect(REGION_STYLES.city.fontFamily).toBe(expectedFont) + expect(REGION_STYLES.default.fontFamily).toBe(expectedFont) + }) + + it('should have drop shadow on all styles', () => { + expect(REGION_STYLES.sect.dropShadow).toBeDefined() + expect(REGION_STYLES.city.dropShadow).toBeDefined() + expect(REGION_STYLES.default.dropShadow).toBeDefined() + }) + }) + + describe('getRegionTextStyle', () => { + it('should return sect style for sect type', () => { + const style = getRegionTextStyle('sect') + expect(style).toBe(REGION_STYLES.sect) + }) + + it('should return city style for city type', () => { + const style = getRegionTextStyle('city') + expect(style).toBe(REGION_STYLES.city) + }) + + it('should return default style for default type', () => { + const style = getRegionTextStyle('default') + expect(style).toBe(REGION_STYLES.default) + }) + + it('should return default style for unknown type', () => { + const style = getRegionTextStyle('unknown') + expect(style).toBe(REGION_STYLES.default) + }) + + it('should return default style for empty string', () => { + const style = getRegionTextStyle('') + expect(style).toBe(REGION_STYLES.default) + }) + }) +}) diff --git a/web/src/__tests__/utils/procedural.test.ts b/web/src/__tests__/utils/procedural.test.ts new file mode 100644 index 0000000..88e9c46 --- /dev/null +++ b/web/src/__tests__/utils/procedural.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect } from 'vitest' +import { getClusteredTileVariant } from '@/utils/procedural' + +describe('procedural', () => { + describe('getClusteredTileVariant', () => { + it('should return startIndex when count is 0', () => { + const result = getClusteredTileVariant(5, 5, 0) + expect(result).toBe(0) + }) + + it('should return startIndex when count is 1', () => { + const result = getClusteredTileVariant(5, 5, 1) + expect(result).toBe(0) + }) + + it('should return startIndex when count is 1 with custom startIndex', () => { + const result = getClusteredTileVariant(5, 5, 1, 10) + expect(result).toBe(10) + }) + + it('should return value within valid range for count > 1', () => { + const count = 9 + for (let x = 0; x < 20; x++) { + for (let y = 0; y < 20; y++) { + const result = getClusteredTileVariant(x, y, count) + expect(result).toBeGreaterThanOrEqual(0) + expect(result).toBeLessThan(count) + } + } + }) + + it('should return value within valid range with custom startIndex', () => { + const count = 5 + const startIndex = 10 + for (let x = 0; x < 20; x++) { + for (let y = 0; y < 20; y++) { + const result = getClusteredTileVariant(x, y, count, startIndex) + expect(result).toBeGreaterThanOrEqual(startIndex) + expect(result).toBeLessThanOrEqual(startIndex + count - 1) + } + } + }) + + it('should be deterministic for same coordinates', () => { + const x = 42 + const y = 17 + const count = 9 + + const result1 = getClusteredTileVariant(x, y, count) + const result2 = getClusteredTileVariant(x, y, count) + const result3 = getClusteredTileVariant(x, y, count) + + expect(result1).toBe(result2) + expect(result2).toBe(result3) + }) + + it('should produce different values for different coordinates', () => { + const count = 9 + const results = new Set() + + // Sample 100 different coordinates. + for (let i = 0; i < 100; i++) { + const result = getClusteredTileVariant(i * 7, i * 13, count) + results.add(result) + } + + // Should produce multiple different values (not all the same). + expect(results.size).toBeGreaterThan(1) + }) + + it('should show clustering behavior - nearby coordinates tend to have similar values', () => { + const count = 9 + let similarCount = 0 + const samples = 50 + + for (let i = 0; i < samples; i++) { + const x = Math.floor(Math.random() * 100) + const y = Math.floor(Math.random() * 100) + + const center = getClusteredTileVariant(x, y, count) + const neighbor = getClusteredTileVariant(x + 1, y, count) + + // Count how often neighbors have similar values (within 2). + if (Math.abs(center - neighbor) <= 2) { + similarCount++ + } + } + + // With clustering, we expect neighbors to be similar more often than random. + // Random would give similarity ~44% of the time (5/9 * 9 = 55% chance of diff >= 3). + // Clustering should give higher similarity rate. + expect(similarCount).toBeGreaterThan(samples * 0.3) + }) + + it('should handle negative coordinates', () => { + const count = 9 + const result = getClusteredTileVariant(-10, -20, count) + expect(result).toBeGreaterThanOrEqual(0) + expect(result).toBeLessThan(count) + }) + + it('should handle large coordinates', () => { + const count = 9 + const result = getClusteredTileVariant(10000, 20000, count) + expect(result).toBeGreaterThanOrEqual(0) + expect(result).toBeLessThan(count) + }) + + it('should handle floating point coordinates', () => { + const count = 9 + const result = getClusteredTileVariant(5.5, 7.3, count) + expect(result).toBeGreaterThanOrEqual(0) + expect(result).toBeLessThan(count) + }) + }) +}) diff --git a/web/src/__tests__/utils/theme.test.ts b/web/src/__tests__/utils/theme.test.ts new file mode 100644 index 0000000..6070aa4 --- /dev/null +++ b/web/src/__tests__/utils/theme.test.ts @@ -0,0 +1,146 @@ +import { describe, it, expect } from 'vitest' +import { GRADE_COLORS, getEntityColor } from '@/utils/theme' +import type { EffectEntity } from '@/types/core' + +describe('theme', () => { + describe('GRADE_COLORS', () => { + it('should have purple colors for upper grade items', () => { + expect(GRADE_COLORS['上品']).toBe('#c488fd') + expect(GRADE_COLORS['宝物']).toBe('#c488fd') + expect(GRADE_COLORS['SR']).toBe('#c488fd') + expect(GRADE_COLORS['Upper']).toBe('#c488fd') + }) + + it('should have green colors for middle grade items', () => { + expect(GRADE_COLORS['中品']).toBe('#88fdc4') + expect(GRADE_COLORS['R']).toBe('#88fdc4') + expect(GRADE_COLORS['Middle']).toBe('#88fdc4') + }) + + it('should have gold colors for artifact grade items', () => { + expect(GRADE_COLORS['法宝']).toBe('#fddc88') + expect(GRADE_COLORS['SSR']).toBe('#fddc88') + expect(GRADE_COLORS['Artifact']).toBe('#fddc88') + }) + + it('should have default color', () => { + expect(GRADE_COLORS['Default']).toBe('#cccccc') + }) + + it('should have realm colors', () => { + expect(GRADE_COLORS['练气']).toBe('#cccccc') + expect(GRADE_COLORS['筑基']).toBe('#88fdc4') + expect(GRADE_COLORS['金丹']).toBe('#c488fd') + expect(GRADE_COLORS['元婴']).toBe('#fddc88') + }) + }) + + describe('getEntityColor', () => { + it('should return undefined for null entity', () => { + expect(getEntityColor(null)).toBeUndefined() + }) + + it('should return undefined for undefined entity', () => { + expect(getEntityColor(undefined)).toBeUndefined() + }) + + it('should return undefined for empty entity', () => { + expect(getEntityColor({})).toBeUndefined() + }) + + describe('RGB array color', () => { + it('should convert RGB array to rgb string', () => { + const entity: Partial = { + color: [255, 128, 64] + } + expect(getEntityColor(entity)).toBe('rgb(255,128,64)') + }) + + it('should handle RGB array with zeros', () => { + const entity: Partial = { + color: [0, 0, 0] + } + expect(getEntityColor(entity)).toBe('rgb(0,0,0)') + }) + + it('should ignore array with wrong length', () => { + const entity = { + color: [255, 128] as any + } + expect(getEntityColor(entity)).toBeUndefined() + }) + }) + + describe('string color', () => { + it('should return string color directly', () => { + const entity: Partial = { + color: '#ff0000' + } + expect(getEntityColor(entity)).toBe('#ff0000') + }) + + it('should return named color directly', () => { + const entity: Partial = { + color: 'red' + } + expect(getEntityColor(entity)).toBe('red') + }) + }) + + describe('grade-based color', () => { + it('should return color based on grade', () => { + const entity: Partial = { + grade: '上品' + } + expect(getEntityColor(entity)).toBe('#c488fd') + }) + + it('should return color based on rarity when no grade', () => { + const entity: Partial = { + rarity: 'SR' + } + expect(getEntityColor(entity)).toBe('#c488fd') + }) + + it('should prefer grade over rarity', () => { + const entity: Partial = { + grade: '上品', + rarity: '中品' + } + expect(getEntityColor(entity)).toBe('#c488fd') + }) + + it('should match partial grade strings', () => { + const entity: Partial = { + grade: '上品丹药' + } + expect(getEntityColor(entity)).toBe('#c488fd') + }) + + it('should return undefined for unknown grade', () => { + const entity: Partial = { + grade: 'unknown' + } + expect(getEntityColor(entity)).toBeUndefined() + }) + }) + + describe('priority', () => { + it('should prefer RGB array over grade', () => { + const entity: Partial = { + color: [100, 200, 150], + grade: '上品' + } + expect(getEntityColor(entity)).toBe('rgb(100,200,150)') + }) + + it('should prefer string color over grade', () => { + const entity: Partial = { + color: '#custom', + grade: '上品' + } + expect(getEntityColor(entity)).toBe('#custom') + }) + }) + }) +})