test: add comprehensive unit tests for refactored hooks and components

Added 56 unit tests covering:
- usePassengerAnimations hook (7 tests) - boarding/disembarking animations
- useTrainTransforms hook (14 tests) - train positioning and opacity
- useTrackManagement hook (12 tests) - track generation and route transitions
- TrainTerrainBackground component (10 tests) - terrain rendering
- GameHUD component (13 tests) - HUD overlay elements

Also configured vitest to properly inject React for JSX transforms, eliminating
the need for explicit React imports in test files and components.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock
2025-10-01 11:09:35 -05:00
parent a9e0d19734
commit 5d2083903e
7 changed files with 1243 additions and 1 deletions

View File

@@ -170,7 +170,8 @@
"Bash(open http://localhost:3002/games/matching)",
"Bash(open http://localhost:3002/create)",
"Bash(open http://localhost:3002/games/complement-race/practice)",
"Bash(open http://localhost:3002/games/complement-race)"
"Bash(open http://localhost:3002/games/complement-race)",
"Bash(npx vitest run:*)"
],
"deny": [],
"ask": []

View File

@@ -0,0 +1,165 @@
import { render, screen } from '@testing-library/react'
import { describe, test, expect, vi } from 'vitest'
import { GameHUD } from '../GameHUD'
import type { Station, Passenger } from '../../../lib/gameTypes'
// Mock child components
vi.mock('../../PassengerCard', () => ({
PassengerCard: ({ passenger }: { passenger: Passenger }) => (
<div data-testid="passenger-card">{passenger.avatar}</div>
)
}))
vi.mock('../../PressureGauge', () => ({
PressureGauge: ({ pressure }: { pressure: number }) => (
<div data-testid="pressure-gauge">{pressure}</div>
)
}))
describe('GameHUD', () => {
const mockRouteTheme = {
emoji: '🚂',
name: 'Mountain Pass'
}
const mockStations: Station[] = [
{ id: 'station-1', name: 'Station 1', position: 20, icon: '🏭' },
{ id: 'station-2', name: 'Station 2', position: 60, icon: '🏛️' }
]
const mockPassenger: Passenger = {
id: 'passenger-1',
avatar: '👨',
originStationId: 'station-1',
destinationStationId: 'station-2',
isBoarded: false,
isDelivered: false,
isUrgent: false
}
const defaultProps = {
routeTheme: mockRouteTheme,
currentRoute: 1,
periodName: '🌅 Dawn',
timeRemaining: 45,
pressure: 75,
nonDeliveredPassengers: [],
stations: mockStations,
currentQuestion: {
number: 3,
targetSum: 10,
correctAnswer: 7
},
currentInput: '7'
}
test('renders route information', () => {
render(<GameHUD {...defaultProps} />)
expect(screen.getByText('Route 1')).toBeInTheDocument()
expect(screen.getByText('Mountain Pass')).toBeInTheDocument()
expect(screen.getByText('🚂')).toBeInTheDocument()
})
test('renders time of day period', () => {
render(<GameHUD {...defaultProps} />)
expect(screen.getByText('🌅 Dawn')).toBeInTheDocument()
})
test('renders time remaining', () => {
render(<GameHUD {...defaultProps} />)
expect(screen.getByText(/45s/)).toBeInTheDocument()
})
test('renders pressure gauge', () => {
render(<GameHUD {...defaultProps} />)
expect(screen.getByTestId('pressure-gauge')).toBeInTheDocument()
expect(screen.getByText('75')).toBeInTheDocument()
})
test('renders passenger list when passengers exist', () => {
render(<GameHUD {...defaultProps} nonDeliveredPassengers={[mockPassenger]} />)
expect(screen.getByTestId('passenger-card')).toBeInTheDocument()
expect(screen.getByText('👨')).toBeInTheDocument()
})
test('does not render passenger list when empty', () => {
render(<GameHUD {...defaultProps} nonDeliveredPassengers={[]} />)
expect(screen.queryByTestId('passenger-card')).not.toBeInTheDocument()
})
test('renders current question when provided', () => {
render(<GameHUD {...defaultProps} />)
expect(screen.getByText('7')).toBeInTheDocument() // currentInput
expect(screen.getByText('3')).toBeInTheDocument() // question.number
expect(screen.getByText('10')).toBeInTheDocument() // targetSum
expect(screen.getByText('+')).toBeInTheDocument()
expect(screen.getByText('=')).toBeInTheDocument()
})
test('shows question mark when no input', () => {
render(<GameHUD {...defaultProps} currentInput="" />)
expect(screen.getByText('?')).toBeInTheDocument()
})
test('does not render question display when currentQuestion is null', () => {
render(<GameHUD {...defaultProps} currentQuestion={null} />)
expect(screen.queryByText('+')).not.toBeInTheDocument()
expect(screen.queryByText('=')).not.toBeInTheDocument()
})
test('renders multiple passengers', () => {
const passengers = [
mockPassenger,
{ ...mockPassenger, id: 'passenger-2', avatar: '👩' },
{ ...mockPassenger, id: 'passenger-3', avatar: '👧' }
]
render(<GameHUD {...defaultProps} nonDeliveredPassengers={passengers} />)
expect(screen.getAllByTestId('passenger-card')).toHaveLength(3)
expect(screen.getByText('👨')).toBeInTheDocument()
expect(screen.getByText('👩')).toBeInTheDocument()
expect(screen.getByText('👧')).toBeInTheDocument()
})
test('updates when route changes', () => {
const { rerender } = render(<GameHUD {...defaultProps} />)
expect(screen.getByText('Route 1')).toBeInTheDocument()
rerender(<GameHUD {...defaultProps} currentRoute={2} />)
expect(screen.getByText('Route 2')).toBeInTheDocument()
})
test('updates when time remaining changes', () => {
const { rerender } = render(<GameHUD {...defaultProps} />)
expect(screen.getByText(/45s/)).toBeInTheDocument()
rerender(<GameHUD {...defaultProps} timeRemaining={30} />)
expect(screen.getByText(/30s/)).toBeInTheDocument()
})
test('memoization: same props do not cause re-render', () => {
const { rerender, container } = render(<GameHUD {...defaultProps} />)
const initialHTML = container.innerHTML
// Rerender with same props
rerender(<GameHUD {...defaultProps} />)
// Should be memoized (same HTML)
expect(container.innerHTML).toBe(initialHTML)
})
})

View File

@@ -0,0 +1,160 @@
import { render } from '@testing-library/react'
import { describe, test, expect } from 'vitest'
import { TrainTerrainBackground } from '../TrainTerrainBackground'
describe('TrainTerrainBackground', () => {
const mockGroundCircles = [
{ key: 'ground-1', cx: 10, cy: 150, r: 2 },
{ key: 'ground-2', cx: 40, cy: 180, r: 3 }
]
test('renders without crashing', () => {
const { container } = render(
<svg>
<TrainTerrainBackground ballastPath="M 0 300 L 800 300" groundTextureCircles={mockGroundCircles} />
</svg>
)
expect(container).toBeTruthy()
})
test('renders gradient definitions', () => {
const { container } = render(
<svg>
<TrainTerrainBackground ballastPath="M 0 300 L 800 300" groundTextureCircles={mockGroundCircles} />
</svg>
)
const defs = container.querySelector('defs')
expect(defs).toBeTruthy()
// Check for gradient IDs
expect(container.querySelector('#mountainGradientLeft')).toBeTruthy()
expect(container.querySelector('#mountainGradientRight')).toBeTruthy()
expect(container.querySelector('#groundGradient')).toBeTruthy()
})
test('renders ground layer rects', () => {
const { container } = render(
<svg>
<TrainTerrainBackground ballastPath="M 0 300 L 800 300" groundTextureCircles={mockGroundCircles} />
</svg>
)
const rects = container.querySelectorAll('rect')
expect(rects.length).toBeGreaterThan(0)
// Check for ground base layer
const groundRect = Array.from(rects).find(
rect => rect.getAttribute('fill') === '#8B7355' && rect.getAttribute('width') === '900'
)
expect(groundRect).toBeTruthy()
})
test('renders ground texture circles', () => {
const { container } = render(
<svg>
<TrainTerrainBackground ballastPath="M 0 300 L 800 300" groundTextureCircles={mockGroundCircles} />
</svg>
)
const circles = container.querySelectorAll('circle')
expect(circles.length).toBeGreaterThanOrEqual(2)
// Verify circle attributes
const firstCircle = circles[0]
expect(firstCircle.getAttribute('cx')).toBe('10')
expect(firstCircle.getAttribute('cy')).toBe('150')
expect(firstCircle.getAttribute('r')).toBe('2')
})
test('renders ballast path with correct attributes', () => {
const { container } = render(
<svg>
<TrainTerrainBackground ballastPath="M 0 300 L 800 300" groundTextureCircles={mockGroundCircles} />
</svg>
)
const ballastPath = Array.from(container.querySelectorAll('path')).find(
path => path.getAttribute('d') === 'M 0 300 L 800 300' && path.getAttribute('stroke') === '#8B7355'
)
expect(ballastPath).toBeTruthy()
expect(ballastPath?.getAttribute('stroke-width')).toBe('40')
})
test('renders left tunnel structure', () => {
const { container } = render(
<svg>
<TrainTerrainBackground ballastPath="M 0 300 L 800 300" groundTextureCircles={mockGroundCircles} />
</svg>
)
const leftTunnel = container.querySelector('[data-element="left-tunnel"]')
expect(leftTunnel).toBeTruthy()
// Check for tunnel elements
const ellipses = leftTunnel?.querySelectorAll('ellipse')
expect(ellipses?.length).toBeGreaterThan(0)
})
test('renders right tunnel structure', () => {
const { container } = render(
<svg>
<TrainTerrainBackground ballastPath="M 0 300 L 800 300" groundTextureCircles={mockGroundCircles} />
</svg>
)
const rightTunnel = container.querySelector('[data-element="right-tunnel"]')
expect(rightTunnel).toBeTruthy()
// Check for tunnel elements
const ellipses = rightTunnel?.querySelectorAll('ellipse')
expect(ellipses?.length).toBeGreaterThan(0)
})
test('renders mountains with gradient fills', () => {
const { container } = render(
<svg>
<TrainTerrainBackground ballastPath="M 0 300 L 800 300" groundTextureCircles={mockGroundCircles} />
</svg>
)
// Check for paths with gradient fills
const gradientPaths = Array.from(container.querySelectorAll('path')).filter(path =>
path.getAttribute('fill')?.includes('url(#mountainGradient')
)
expect(gradientPaths.length).toBeGreaterThanOrEqual(2)
})
test('handles empty groundTextureCircles array', () => {
const { container } = render(
<svg>
<TrainTerrainBackground ballastPath="M 0 300 L 800 300" groundTextureCircles={[]} />
</svg>
)
// Should still render other elements
expect(container.querySelector('defs')).toBeTruthy()
expect(container.querySelector('[data-element="left-tunnel"]')).toBeTruthy()
})
test('memoization: does not re-render with same props', () => {
const { rerender, container } = render(
<svg>
<TrainTerrainBackground ballastPath="M 0 300 L 800 300" groundTextureCircles={mockGroundCircles} />
</svg>
)
const initialHTML = container.innerHTML
// Rerender with same props
rerender(
<svg>
<TrainTerrainBackground ballastPath="M 0 300 L 800 300" groundTextureCircles={mockGroundCircles} />
</svg>
)
// HTML should be identical (component memoized)
expect(container.innerHTML).toBe(initialHTML)
})
})

View File

@@ -0,0 +1,279 @@
import { renderHook } from '@testing-library/react'
import { describe, test, expect, beforeEach, vi } from 'vitest'
import { usePassengerAnimations } from '../usePassengerAnimations'
import type { Passenger, Station } from '../../lib/gameTypes'
import type { RailroadTrackGenerator } from '../../lib/RailroadTrackGenerator'
describe('usePassengerAnimations', () => {
let mockPathRef: React.RefObject<SVGPathElement>
let mockTrackGenerator: RailroadTrackGenerator
let mockStation1: Station
let mockStation2: Station
let mockPassenger1: Passenger
let mockPassenger2: Passenger
beforeEach(() => {
// Create mock path element
const mockPath = document.createElementNS('http://www.w3.org/2000/svg', 'path')
mockPathRef = { current: mockPath }
// Mock track generator
mockTrackGenerator = {
getTrainTransform: vi.fn((path: SVGPathElement, position: number) => ({
x: position * 10,
y: 300,
rotation: 0
}))
} as unknown as RailroadTrackGenerator
// Create mock stations
mockStation1 = {
id: 'station-1',
name: 'Station 1',
position: 20,
icon: '🏭'
}
mockStation2 = {
id: 'station-2',
name: 'Station 2',
position: 60,
icon: '🏛️'
}
// Create mock passengers
mockPassenger1 = {
id: 'passenger-1',
avatar: '👨',
originStationId: 'station-1',
destinationStationId: 'station-2',
isBoarded: false,
isDelivered: false,
isUrgent: false
}
mockPassenger2 = {
id: 'passenger-2',
avatar: '👩',
originStationId: 'station-1',
destinationStationId: 'station-2',
isBoarded: false,
isDelivered: false,
isUrgent: true
}
vi.clearAllMocks()
})
test('initializes with empty animation maps', () => {
const { result } = renderHook(() =>
usePassengerAnimations({
passengers: [],
stations: [mockStation1, mockStation2],
stationPositions: [
{ x: 100, y: 300 },
{ x: 500, y: 300 }
],
trainPosition: 0,
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef
})
)
expect(result.current.boardingAnimations.size).toBe(0)
expect(result.current.disembarkingAnimations.size).toBe(0)
})
test('creates boarding animation when passenger boards', () => {
const { result, rerender } = renderHook(
({ passengers }) =>
usePassengerAnimations({
passengers,
stations: [mockStation1, mockStation2],
stationPositions: [
{ x: 100, y: 300 },
{ x: 500, y: 300 }
],
trainPosition: 20,
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef
}),
{
initialProps: {
passengers: [mockPassenger1]
}
}
)
// Initially no boarding animations
expect(result.current.boardingAnimations.size).toBe(0)
// Passenger boards
const boardedPassenger = { ...mockPassenger1, isBoarded: true }
rerender({ passengers: [boardedPassenger] })
// Should create boarding animation
expect(result.current.boardingAnimations.size).toBe(1)
expect(result.current.boardingAnimations.has('passenger-1')).toBe(true)
const animation = result.current.boardingAnimations.get('passenger-1')
expect(animation).toBeDefined()
expect(animation?.passenger).toEqual(boardedPassenger)
expect(animation?.fromX).toBe(100) // Station position
expect(animation?.fromY).toBe(270) // Station position - 30
expect(mockTrackGenerator.getTrainTransform).toHaveBeenCalled()
})
test('creates disembarking animation when passenger is delivered', () => {
const boardedPassenger = { ...mockPassenger1, isBoarded: true }
const { result, rerender } = renderHook(
({ passengers }) =>
usePassengerAnimations({
passengers,
stations: [mockStation1, mockStation2],
stationPositions: [
{ x: 100, y: 300 },
{ x: 500, y: 300 }
],
trainPosition: 60,
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef
}),
{
initialProps: {
passengers: [boardedPassenger]
}
}
)
// Initially no disembarking animations
expect(result.current.disembarkingAnimations.size).toBe(0)
// Passenger is delivered
const deliveredPassenger = { ...boardedPassenger, isDelivered: true }
rerender({ passengers: [deliveredPassenger] })
// Should create disembarking animation
expect(result.current.disembarkingAnimations.size).toBe(1)
expect(result.current.disembarkingAnimations.has('passenger-1')).toBe(true)
const animation = result.current.disembarkingAnimations.get('passenger-1')
expect(animation).toBeDefined()
expect(animation?.passenger).toEqual(deliveredPassenger)
expect(animation?.toX).toBe(500) // Destination station position
expect(animation?.toY).toBe(270) // Station position - 30
})
test('handles multiple passengers boarding simultaneously', () => {
const { result, rerender } = renderHook(
({ passengers }) =>
usePassengerAnimations({
passengers,
stations: [mockStation1, mockStation2],
stationPositions: [
{ x: 100, y: 300 },
{ x: 500, y: 300 }
],
trainPosition: 20,
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef
}),
{
initialProps: {
passengers: [mockPassenger1, mockPassenger2]
}
}
)
// Both passengers board
const boardedPassengers = [
{ ...mockPassenger1, isBoarded: true },
{ ...mockPassenger2, isBoarded: true }
]
rerender({ passengers: boardedPassengers })
// Should create boarding animations for both
expect(result.current.boardingAnimations.size).toBe(2)
expect(result.current.boardingAnimations.has('passenger-1')).toBe(true)
expect(result.current.boardingAnimations.has('passenger-2')).toBe(true)
})
test('does not create animation if passenger already boarded in previous state', () => {
const boardedPassenger = { ...mockPassenger1, isBoarded: true }
const { result } = renderHook(() =>
usePassengerAnimations({
passengers: [boardedPassenger],
stations: [mockStation1, mockStation2],
stationPositions: [
{ x: 100, y: 300 },
{ x: 500, y: 300 }
],
trainPosition: 20,
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef
})
)
// No animation since passenger was already boarded
expect(result.current.boardingAnimations.size).toBe(0)
})
test('returns empty animations when pathRef is null', () => {
const nullPathRef: React.RefObject<SVGPathElement> = { current: null }
const { result, rerender } = renderHook(
({ passengers }) =>
usePassengerAnimations({
passengers,
stations: [mockStation1, mockStation2],
stationPositions: [
{ x: 100, y: 300 },
{ x: 500, y: 300 }
],
trainPosition: 20,
trackGenerator: mockTrackGenerator,
pathRef: nullPathRef
}),
{
initialProps: {
passengers: [mockPassenger1]
}
}
)
// Passenger boards
const boardedPassenger = { ...mockPassenger1, isBoarded: true }
rerender({ passengers: [boardedPassenger] })
// Should not create animation without path
expect(result.current.boardingAnimations.size).toBe(0)
})
test('returns empty animations when stationPositions is empty', () => {
const { result, rerender } = renderHook(
({ passengers }) =>
usePassengerAnimations({
passengers,
stations: [mockStation1, mockStation2],
stationPositions: [],
trainPosition: 20,
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef
}),
{
initialProps: {
passengers: [mockPassenger1]
}
}
)
// Passenger boards
const boardedPassenger = { ...mockPassenger1, isBoarded: true }
rerender({ passengers: [boardedPassenger] })
// Should not create animation without station positions
expect(result.current.boardingAnimations.size).toBe(0)
})
})

View File

@@ -0,0 +1,358 @@
import { renderHook, act } from '@testing-library/react'
import { describe, test, expect, beforeEach, vi } from 'vitest'
import { useTrackManagement } from '../useTrackManagement'
import type { RailroadTrackGenerator } from '../../lib/RailroadTrackGenerator'
import type { Station, Passenger } from '../../lib/gameTypes'
// Mock the landmarks module
vi.mock('../../lib/landmarks', () => ({
generateLandmarks: vi.fn((route: number) => [
{ emoji: '🌲', position: 30, offset: { x: 0, y: -50 }, size: 24 },
{ emoji: '🏔️', position: 70, offset: { x: 0, y: -80 }, size: 32 }
])
}))
describe('useTrackManagement', () => {
let mockPathRef: React.RefObject<SVGPathElement>
let mockTrackGenerator: RailroadTrackGenerator
let mockStations: Station[]
let mockPassengers: Passenger[]
beforeEach(() => {
// Create mock path element
const mockPath = document.createElementNS('http://www.w3.org/2000/svg', 'path')
mockPath.getTotalLength = vi.fn(() => 1000)
mockPath.getPointAtLength = vi.fn((distance: number) => ({
x: distance,
y: 300
}))
mockPathRef = { current: mockPath }
// Mock track generator
mockTrackGenerator = {
generateTrack: vi.fn((route: number) => ({
referencePath: `M 0 300 L ${route * 100} 300`,
ballastPath: `M 0 300 L ${route * 100} 300`
})),
generateTiesAndRails: vi.fn(() => ({
ties: [
{ x1: 0, y1: 300, x2: 10, y2: 300 },
{ x1: 20, y1: 300, x2: 30, y2: 300 }
],
leftRailPoints: ['0,295', '100,295'],
rightRailPoints: ['0,305', '100,305']
}))
} as unknown as RailroadTrackGenerator
mockStations = [
{ id: 'station-1', name: 'Station 1', position: 20, icon: '🏭' },
{ id: 'station-2', name: 'Station 2', position: 60, icon: '🏛️' }
]
mockPassengers = [
{
id: 'passenger-1',
avatar: '👨',
originStationId: 'station-1',
destinationStationId: 'station-2',
isBoarded: false,
isDelivered: false,
isUrgent: false
}
]
vi.clearAllMocks()
})
test('initializes with null trackData', () => {
const { result } = renderHook(() =>
useTrackManagement({
currentRoute: 1,
trainPosition: 0,
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef,
stations: mockStations,
passengers: mockPassengers
})
)
// Track data should be generated
expect(result.current.trackData).toBeDefined()
expect(mockTrackGenerator.generateTrack).toHaveBeenCalledWith(1)
})
test('generates landmarks for current route', () => {
const { result } = renderHook(() =>
useTrackManagement({
currentRoute: 1,
trainPosition: 0,
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef,
stations: mockStations,
passengers: mockPassengers
})
)
expect(result.current.landmarks).toHaveLength(2)
expect(result.current.landmarks[0].emoji).toBe('🌲')
expect(result.current.landmarks[1].emoji).toBe('🏔️')
})
test('generates ties and rails when path is ready', () => {
const { result } = renderHook(() =>
useTrackManagement({
currentRoute: 1,
trainPosition: 0,
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef,
stations: mockStations,
passengers: mockPassengers
})
)
expect(result.current.tiesAndRails).toBeDefined()
expect(result.current.tiesAndRails?.ties).toHaveLength(2)
})
test('calculates station positions along path', () => {
const { result } = renderHook(() =>
useTrackManagement({
currentRoute: 1,
trainPosition: 0,
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef,
stations: mockStations,
passengers: mockPassengers
})
)
expect(result.current.stationPositions).toHaveLength(2)
// Station 1 at 20% of 1000 = 200
expect(result.current.stationPositions[0].x).toBe(200)
// Station 2 at 60% of 1000 = 600
expect(result.current.stationPositions[1].x).toBe(600)
})
test('calculates landmark positions along path', () => {
const { result } = renderHook(() =>
useTrackManagement({
currentRoute: 1,
trainPosition: 0,
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef,
stations: mockStations,
passengers: mockPassengers
})
)
expect(result.current.landmarkPositions).toHaveLength(2)
// First landmark at 30% + offset
expect(result.current.landmarkPositions[0].x).toBe(300) // 30% of 1000
expect(result.current.landmarkPositions[0].y).toBe(250) // 300 + (-50)
})
test('delays track update when changing routes mid-journey', () => {
const { result, rerender } = renderHook(
({ route, position }) =>
useTrackManagement({
currentRoute: route,
trainPosition: position,
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef,
stations: mockStations,
passengers: mockPassengers
}),
{
initialProps: { route: 1, position: 0 }
}
)
const initialTrackData = result.current.trackData
// Change route while train is mid-journey (position > 0)
rerender({ route: 2, position: 50 })
// Track should NOT update yet (pending)
expect(result.current.trackData).toBe(initialTrackData)
expect(mockTrackGenerator.generateTrack).toHaveBeenCalledWith(2)
})
test('applies pending track when train resets to beginning', () => {
const { result, rerender } = renderHook(
({ route, position }) =>
useTrackManagement({
currentRoute: route,
trainPosition: position,
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef,
stations: mockStations,
passengers: mockPassengers
}),
{
initialProps: { route: 1, position: 0 }
}
)
// Change route while train is mid-journey
rerender({ route: 2, position: 50 })
const trackDataBeforeReset = result.current.trackData
// Train resets to beginning (position < 0)
rerender({ route: 2, position: -5 })
// Track should now update
expect(result.current.trackData).not.toBe(trackDataBeforeReset)
})
test('immediately applies new track when train is at start', () => {
const { result, rerender } = renderHook(
({ route, position }) =>
useTrackManagement({
currentRoute: route,
trainPosition: position,
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef,
stations: mockStations,
passengers: mockPassengers
}),
{
initialProps: { route: 1, position: -5 }
}
)
const initialTrackData = result.current.trackData
// Change route while train is at start (position < 0)
rerender({ route: 2, position: -5 })
// Track should update immediately
expect(result.current.trackData).not.toBe(initialTrackData)
})
test('delays passenger display update until all cars exit', () => {
const newPassengers: Passenger[] = [
{
id: 'passenger-2',
avatar: '👩',
originStationId: 'station-1',
destinationStationId: 'station-2',
isBoarded: false,
isDelivered: false,
isUrgent: false
}
]
const { result, rerender } = renderHook(
({ passengers, position }) =>
useTrackManagement({
currentRoute: 1,
trainPosition: position,
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef,
stations: mockStations,
passengers,
maxCars: 5,
carSpacing: 7
}),
{
initialProps: { passengers: mockPassengers, position: 50 }
}
)
expect(result.current.displayPassengers).toBe(mockPassengers)
// Change passengers while train is mid-journey
// Locomotive at 100%, but last car at 100 - (5*7) = 65%
rerender({ passengers: newPassengers, position: 100 })
// Display passengers should NOT update yet (last car hasn't exited)
expect(result.current.displayPassengers).toBe(mockPassengers)
})
test('updates passenger display after all cars exit', () => {
const newPassengers: Passenger[] = [
{
id: 'passenger-2',
avatar: '👩',
originStationId: 'station-1',
destinationStationId: 'station-2',
isBoarded: false,
isDelivered: false,
isUrgent: false
}
]
const { result, rerender } = renderHook(
({ passengers, position }) =>
useTrackManagement({
currentRoute: 1,
trainPosition: position,
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef,
stations: mockStations,
passengers,
maxCars: 5,
carSpacing: 7
}),
{
initialProps: { passengers: mockPassengers, position: 50 }
}
)
// Change passengers, locomotive at position where last car has exited
// Last car exits at position 97%, so locomotive needs to be at 97 + (5*7) = 132%
rerender({ passengers: newPassengers, position: 132 })
// Display passengers should update now (all cars exited)
expect(result.current.displayPassengers).toBe(newPassengers)
})
test('updates passengers immediately during same route', () => {
const updatedPassengers: Passenger[] = [
{ ...mockPassengers[0], isBoarded: true }
]
const { result, rerender } = renderHook(
({ passengers, position }) =>
useTrackManagement({
currentRoute: 1,
trainPosition: position,
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef,
stations: mockStations,
passengers,
maxCars: 5,
carSpacing: 7
}),
{
initialProps: { passengers: mockPassengers, position: 50 }
}
)
// Update passengers (boarding) during same route
rerender({ passengers: updatedPassengers, position: 55 })
// Display passengers should update immediately (same route, gameplay update)
expect(result.current.displayPassengers).toBe(updatedPassengers)
})
test('returns null when no track data', () => {
// Create a hook where trackGenerator returns null
const nullTrackGenerator = {
generateTrack: vi.fn(() => null)
} as unknown as RailroadTrackGenerator
const { result } = renderHook(() =>
useTrackManagement({
currentRoute: 1,
trainPosition: 0,
trackGenerator: nullTrackGenerator,
pathRef: mockPathRef,
stations: mockStations,
passengers: mockPassengers
})
)
expect(result.current.trackData).toBeNull()
})
})

View File

@@ -0,0 +1,276 @@
import { renderHook } from '@testing-library/react'
import { describe, test, expect, beforeEach, vi } from 'vitest'
import { useTrainTransforms } from '../useTrainTransforms'
import type { RailroadTrackGenerator } from '../../lib/RailroadTrackGenerator'
describe('useTrainTransforms', () => {
let mockPathRef: React.RefObject<SVGPathElement>
let mockTrackGenerator: RailroadTrackGenerator
beforeEach(() => {
// Create mock path element
const mockPath = document.createElementNS('http://www.w3.org/2000/svg', 'path')
mockPathRef = { current: mockPath }
// Mock track generator
mockTrackGenerator = {
getTrainTransform: vi.fn((path: SVGPathElement, position: number) => ({
x: position * 10,
y: 300,
rotation: position / 10
}))
} as unknown as RailroadTrackGenerator
vi.clearAllMocks()
})
test('returns default transform when pathRef is null', () => {
const nullPathRef: React.RefObject<SVGPathElement> = { current: null }
const { result } = renderHook(() =>
useTrainTransforms({
trainPosition: 50,
trackGenerator: mockTrackGenerator,
pathRef: nullPathRef
})
)
expect(result.current.trainTransform).toEqual({ x: 50, y: 300, rotation: 0 })
expect(result.current.maxCars).toBe(5)
expect(result.current.carSpacing).toBe(7)
})
test('calculates train transform at given position', () => {
const { result } = renderHook(() =>
useTrainTransforms({
trainPosition: 50,
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef
})
)
expect(result.current.trainTransform).toEqual({
x: 500, // 50 * 10
y: 300,
rotation: 5 // 50 / 10
})
})
test('updates transform when train position changes', () => {
const { result, rerender } = renderHook(
({ position }) =>
useTrainTransforms({
trainPosition: position,
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef
}),
{ initialProps: { position: 20 } }
)
expect(result.current.trainTransform.x).toBe(200)
rerender({ position: 60 })
expect(result.current.trainTransform.x).toBe(600)
})
test('calculates correct number of train cars with default maxCars', () => {
const { result } = renderHook(() =>
useTrainTransforms({
trainPosition: 50,
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef
})
)
expect(result.current.trainCars).toHaveLength(5)
expect(result.current.maxCars).toBe(5)
})
test('respects custom maxCars parameter', () => {
const { result } = renderHook(() =>
useTrainTransforms({
trainPosition: 50,
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef,
maxCars: 3
})
)
expect(result.current.trainCars).toHaveLength(3)
expect(result.current.maxCars).toBe(3)
})
test('respects custom carSpacing parameter', () => {
const { result } = renderHook(() =>
useTrainTransforms({
trainPosition: 50,
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef,
carSpacing: 10
})
)
expect(result.current.carSpacing).toBe(10)
// First car should be at position 50 - 10 = 40
expect(result.current.trainCars[0].position).toBe(40)
})
test('positions cars behind locomotive with correct spacing', () => {
const { result } = renderHook(() =>
useTrainTransforms({
trainPosition: 50,
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef,
maxCars: 3,
carSpacing: 10
})
)
expect(result.current.trainCars[0].position).toBe(40) // 50 - 1*10
expect(result.current.trainCars[1].position).toBe(30) // 50 - 2*10
expect(result.current.trainCars[2].position).toBe(20) // 50 - 3*10
})
test('calculates locomotive opacity correctly during fade in', () => {
// Fade in range: 3-8%
const { result: result1 } = renderHook(() =>
useTrainTransforms({
trainPosition: 3,
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef
})
)
expect(result1.current.locomotiveOpacity).toBe(0)
const { result: result2 } = renderHook(() =>
useTrainTransforms({
trainPosition: 5.5, // Midpoint between 3 and 8
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef
})
)
expect(result2.current.locomotiveOpacity).toBe(0.5)
const { result: result3 } = renderHook(() =>
useTrainTransforms({
trainPosition: 8,
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef
})
)
expect(result3.current.locomotiveOpacity).toBe(1)
})
test('calculates locomotive opacity correctly during fade out', () => {
// Fade out range: 92-97%
const { result: result1 } = renderHook(() =>
useTrainTransforms({
trainPosition: 92,
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef
})
)
expect(result1.current.locomotiveOpacity).toBe(1)
const { result: result2 } = renderHook(() =>
useTrainTransforms({
trainPosition: 94.5, // Midpoint between 92 and 97
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef
})
)
expect(result2.current.locomotiveOpacity).toBe(0.5)
const { result: result3 } = renderHook(() =>
useTrainTransforms({
trainPosition: 97,
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef
})
)
expect(result3.current.locomotiveOpacity).toBe(0)
})
test('locomotive is fully visible in middle of track', () => {
const { result } = renderHook(() =>
useTrainTransforms({
trainPosition: 50,
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef
})
)
expect(result.current.locomotiveOpacity).toBe(1)
})
test('calculates car opacity independently for each car', () => {
const { result } = renderHook(() =>
useTrainTransforms({
trainPosition: 10, // Locomotive at 10%, first car at 3% (fading in)
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef,
maxCars: 2,
carSpacing: 7
})
)
// First car at position 3 should be starting to fade in
expect(result.current.trainCars[0].position).toBe(3)
expect(result.current.trainCars[0].opacity).toBe(0)
// Second car at position -4 should be invisible (not yet entered)
expect(result.current.trainCars[1].position).toBe(0) // clamped to 0
expect(result.current.trainCars[1].opacity).toBe(0)
})
test('car positions cannot go below zero', () => {
const { result } = renderHook(() =>
useTrainTransforms({
trainPosition: 5,
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef,
maxCars: 3,
carSpacing: 7
})
)
// First car at 5 - 7 = -2, should be clamped to 0
expect(result.current.trainCars[0].position).toBe(0)
// Second car at 5 - 14 = -9, should be clamped to 0
expect(result.current.trainCars[1].position).toBe(0)
})
test('cars fade out completely past 97%', () => {
const { result } = renderHook(() =>
useTrainTransforms({
trainPosition: 104, // Last car at 104 - 35 = 69% (5 cars * 7 spacing)
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef,
maxCars: 5,
carSpacing: 7
})
)
const lastCar = result.current.trainCars[4]
expect(lastCar.position).toBe(69)
expect(lastCar.opacity).toBe(1) // Still visible, not past 97%
})
test('memoizes car transforms to avoid recalculation on same inputs', () => {
const { result, rerender } = renderHook(() =>
useTrainTransforms({
trainPosition: 50,
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef
})
)
const firstCars = result.current.trainCars
// Rerender with same props
rerender()
// Should be the exact same array reference (memoized)
expect(result.current.trainCars).toBe(firstCars)
})
})

View File

@@ -3,6 +3,9 @@ import { defineConfig } from 'vitest/config'
import path from 'path'
export default defineConfig({
esbuild: {
jsxInject: `import React from 'react'`,
},
test: {
globals: true,
environment: 'jsdom',