This commit captures work from multiple sessions building the statistics system and frontend component library. Backend - Phase 3.5: Statistics System - Box score statistics with materialized views - Play stat calculator for real-time updates - Stat view refresher service - Alembic migration for materialized views - Test coverage: 41 new tests (all passing) Frontend - Phase F1: Foundation - Composables: useGameState, useGameActions, useWebSocket - Type definitions and interfaces - Store setup with Pinia Frontend - Phase F2: Game Display - ScoreBoard, GameBoard, CurrentSituation, PlayByPlay components - Demo page at /demo Frontend - Phase F3: Decision Inputs - DefensiveSetup, OffensiveApproach, StolenBaseInputs components - DecisionPanel orchestration - Demo page at /demo-decisions - Test coverage: 213 tests passing Frontend - Phase F4: Dice & Manual Outcome - DiceRoller component - ManualOutcomeEntry with validation - PlayResult display - GameplayPanel orchestration - Demo page at /demo-gameplay - Test coverage: 119 tests passing Frontend - Phase F5: Substitutions - PinchHitterSelector, DefensiveReplacementSelector, PitchingChangeSelector - SubstitutionPanel with tab navigation - Demo page at /demo-substitutions - Test coverage: 114 tests passing Documentation: - PHASE_3_5_HANDOFF.md - Statistics system handoff - PHASE_F2_COMPLETE.md - Game display completion - Frontend phase planning docs - NEXT_SESSION.md updated for Phase F6 Configuration: - Package updates (Nuxt 4 fixes) - Tailwind config enhancements - Game store updates Test Status: - Backend: 731/731 passing (100%) - Frontend: 446/446 passing (100%) - Total: 1,177 tests passing Next Phase: F6 - Integration (wire all components into game page) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
477 lines
14 KiB
TypeScript
477 lines
14 KiB
TypeScript
import { describe, it, expect } from 'vitest'
|
|
import { mount } from '@vue/test-utils'
|
|
import PitchingChangeSelector from '~/components/Substitutions/PitchingChangeSelector.vue'
|
|
import type { Lineup, SbaPlayer } from '~/types'
|
|
|
|
// Helper to create mock player
|
|
function createMockPlayer(id: number, name: string, positions: string[]): SbaPlayer {
|
|
const player: SbaPlayer = {
|
|
id,
|
|
name,
|
|
image: `player-${id}.jpg`,
|
|
pos_1: null,
|
|
pos_2: null,
|
|
pos_3: null,
|
|
pos_4: null,
|
|
pos_5: null,
|
|
pos_6: null,
|
|
pos_7: null,
|
|
pos_8: null,
|
|
wara: 1.5,
|
|
team_id: 1,
|
|
team_name: 'Test Team',
|
|
season: '2024',
|
|
strat_code: null,
|
|
bbref_id: null,
|
|
injury_rating: null,
|
|
}
|
|
|
|
positions.forEach((pos, index) => {
|
|
const key = `pos_${index + 1}` as keyof SbaPlayer
|
|
;(player as any)[key] = pos
|
|
})
|
|
|
|
return player
|
|
}
|
|
|
|
// Helper to create mock lineup entry
|
|
function createMockLineup(
|
|
id: number,
|
|
player: SbaPlayer,
|
|
isActive: boolean,
|
|
position = 'P',
|
|
isFatigued = false,
|
|
enteredInning = 1
|
|
): Lineup {
|
|
return {
|
|
id,
|
|
game_id: 'test-game-123',
|
|
team_id: 1,
|
|
player_id: player.id,
|
|
position,
|
|
batting_order: null,
|
|
is_starter: true,
|
|
is_active: isActive,
|
|
entered_inning: enteredInning,
|
|
replacing_id: null,
|
|
after_play: null,
|
|
is_fatigued: isFatigued,
|
|
player,
|
|
}
|
|
}
|
|
|
|
describe('PitchingChangeSelector', () => {
|
|
const mockCurrentPitcher = createMockLineup(
|
|
1,
|
|
createMockPlayer(101, 'John Starter', ['P']),
|
|
true,
|
|
'P',
|
|
false,
|
|
1
|
|
)
|
|
|
|
const mockBenchPlayers = [
|
|
createMockLineup(2, createMockPlayer(201, 'Relief Pitcher 1', ['P']), false),
|
|
createMockLineup(3, createMockPlayer(202, 'Relief Pitcher 2', ['P']), false),
|
|
createMockLineup(4, createMockPlayer(203, 'Position Player', ['1B', 'OF']), false),
|
|
]
|
|
|
|
const defaultProps = {
|
|
currentPitcher: mockCurrentPitcher,
|
|
benchPlayers: mockBenchPlayers,
|
|
teamId: 1,
|
|
}
|
|
|
|
describe('Rendering', () => {
|
|
it('renders component with header', () => {
|
|
const wrapper = mount(PitchingChangeSelector, {
|
|
props: defaultProps,
|
|
})
|
|
|
|
expect(wrapper.text()).toContain('Pitching Change')
|
|
})
|
|
|
|
it('displays current pitcher information', () => {
|
|
const wrapper = mount(PitchingChangeSelector, {
|
|
props: defaultProps,
|
|
})
|
|
|
|
expect(wrapper.text()).toContain('Current Pitcher:')
|
|
expect(wrapper.text()).toContain('John Starter')
|
|
expect(wrapper.text()).toContain('Entered Inning 1')
|
|
})
|
|
|
|
it('shows descriptive text with pitcher name', () => {
|
|
const wrapper = mount(PitchingChangeSelector, {
|
|
props: defaultProps,
|
|
})
|
|
|
|
expect(wrapper.text()).toContain('Bring in a relief pitcher to replace John Starter')
|
|
})
|
|
|
|
it('renders action buttons', () => {
|
|
const wrapper = mount(PitchingChangeSelector, {
|
|
props: defaultProps,
|
|
})
|
|
|
|
expect(wrapper.text()).toContain('Cancel')
|
|
expect(wrapper.text()).toContain('Bring in Reliever')
|
|
})
|
|
})
|
|
|
|
describe('Pitcher Filtering', () => {
|
|
it('filters to only show pitchers from bench', () => {
|
|
const wrapper = mount(PitchingChangeSelector, {
|
|
props: defaultProps,
|
|
})
|
|
|
|
expect(wrapper.text()).toContain('Relief Pitcher 1')
|
|
expect(wrapper.text()).toContain('Relief Pitcher 2')
|
|
expect(wrapper.text()).not.toContain('Position Player')
|
|
})
|
|
|
|
it('filters out active pitchers', () => {
|
|
const mixedBench = [
|
|
createMockLineup(2, createMockPlayer(201, 'Active Pitcher', ['P']), true),
|
|
createMockLineup(3, createMockPlayer(202, 'Bench Pitcher', ['P']), false),
|
|
]
|
|
|
|
const wrapper = mount(PitchingChangeSelector, {
|
|
props: {
|
|
...defaultProps,
|
|
benchPlayers: mixedBench,
|
|
},
|
|
})
|
|
|
|
expect(wrapper.text()).not.toContain('Active Pitcher')
|
|
expect(wrapper.text()).toContain('Bench Pitcher')
|
|
})
|
|
|
|
it('shows fatigued pitchers but marks them as unavailable', () => {
|
|
const mixedBench = [
|
|
createMockLineup(2, createMockPlayer(201, 'Fatigued Pitcher', ['P']), false, 'P', true),
|
|
createMockLineup(3, createMockPlayer(202, 'Fresh Pitcher', ['P']), false),
|
|
]
|
|
|
|
const wrapper = mount(PitchingChangeSelector, {
|
|
props: {
|
|
...defaultProps,
|
|
benchPlayers: mixedBench,
|
|
},
|
|
})
|
|
|
|
expect(wrapper.text()).toContain('Fatigued Pitcher')
|
|
expect(wrapper.text()).toContain('Fatigued')
|
|
expect(wrapper.text()).toContain('Fresh Pitcher')
|
|
})
|
|
|
|
it('shows no pitchers message when no eligible relievers', () => {
|
|
const wrapper = mount(PitchingChangeSelector, {
|
|
props: {
|
|
...defaultProps,
|
|
benchPlayers: [
|
|
createMockLineup(2, createMockPlayer(201, 'Only Fielder', ['1B']), false),
|
|
],
|
|
},
|
|
})
|
|
|
|
expect(wrapper.text()).toContain('No relief pitchers available')
|
|
})
|
|
})
|
|
|
|
describe('Pitcher Selection', () => {
|
|
it('allows selecting a relief pitcher', async () => {
|
|
const wrapper = mount(PitchingChangeSelector, {
|
|
props: defaultProps,
|
|
})
|
|
|
|
const pitcherCards = wrapper.findAll('.reliever-card')
|
|
await pitcherCards[0].trigger('click')
|
|
|
|
expect(wrapper.vm.selectedPitcherId).toBe(201)
|
|
})
|
|
|
|
it('highlights selected pitcher', async () => {
|
|
const wrapper = mount(PitchingChangeSelector, {
|
|
props: defaultProps,
|
|
})
|
|
|
|
const pitcherCards = wrapper.findAll('.reliever-card')
|
|
await pitcherCards[0].trigger('click')
|
|
await wrapper.vm.$nextTick()
|
|
|
|
expect(pitcherCards[0].classes()).toContain('reliever-selected')
|
|
})
|
|
|
|
it('shows checkmark on selected pitcher', async () => {
|
|
const wrapper = mount(PitchingChangeSelector, {
|
|
props: defaultProps,
|
|
})
|
|
|
|
const pitcherCards = wrapper.findAll('.reliever-card')
|
|
await pitcherCards[0].trigger('click')
|
|
await wrapper.vm.$nextTick()
|
|
|
|
expect(pitcherCards[0].find('.selected-indicator').exists()).toBe(true)
|
|
})
|
|
|
|
it('prevents selecting fatigued pitcher', async () => {
|
|
const mixedBench = [
|
|
createMockLineup(2, createMockPlayer(201, 'Fatigued Pitcher', ['P']), false, 'P', true),
|
|
]
|
|
|
|
const wrapper = mount(PitchingChangeSelector, {
|
|
props: {
|
|
...defaultProps,
|
|
benchPlayers: mixedBench,
|
|
},
|
|
})
|
|
|
|
const pitcherCards = wrapper.findAll('.reliever-card')
|
|
await pitcherCards[0].trigger('click')
|
|
|
|
expect(wrapper.vm.selectedPitcherId).toBeNull()
|
|
})
|
|
|
|
it('allows changing selection to different pitcher', async () => {
|
|
const wrapper = mount(PitchingChangeSelector, {
|
|
props: defaultProps,
|
|
})
|
|
|
|
const pitcherCards = wrapper.findAll('.reliever-card')
|
|
await pitcherCards[0].trigger('click')
|
|
expect(wrapper.vm.selectedPitcherId).toBe(201)
|
|
|
|
await pitcherCards[1].trigger('click')
|
|
expect(wrapper.vm.selectedPitcherId).toBe(202)
|
|
})
|
|
})
|
|
|
|
describe('Fatigue Display', () => {
|
|
it('shows fatigue indicator for current pitcher when fatigued', () => {
|
|
const fatiguedPitcher = createMockLineup(
|
|
1,
|
|
createMockPlayer(101, 'Tired Starter', ['P']),
|
|
true,
|
|
'P',
|
|
true
|
|
)
|
|
|
|
const wrapper = mount(PitchingChangeSelector, {
|
|
props: {
|
|
...defaultProps,
|
|
currentPitcher: fatiguedPitcher,
|
|
},
|
|
})
|
|
|
|
expect(wrapper.text()).toContain('Fatigued')
|
|
})
|
|
|
|
it('shows fatigue badge on relief pitchers who are fatigued', () => {
|
|
const mixedBench = [
|
|
createMockLineup(2, createMockPlayer(201, 'Fatigued Reliever', ['P']), false, 'P', true),
|
|
]
|
|
|
|
const wrapper = mount(PitchingChangeSelector, {
|
|
props: {
|
|
...defaultProps,
|
|
benchPlayers: mixedBench,
|
|
},
|
|
})
|
|
|
|
const fatigueBadges = wrapper.findAll('.fatigue-badge')
|
|
expect(fatigueBadges.length).toBeGreaterThan(0)
|
|
})
|
|
})
|
|
|
|
describe('Submit Validation', () => {
|
|
it('disables submit when no pitcher selected', () => {
|
|
const wrapper = mount(PitchingChangeSelector, {
|
|
props: defaultProps,
|
|
})
|
|
|
|
const submitButton = wrapper.findAll('button').find(btn => btn.text().includes('Bring in Reliever'))
|
|
expect(submitButton?.attributes('disabled')).toBeDefined()
|
|
})
|
|
|
|
it('enables submit when fresh pitcher selected', async () => {
|
|
const wrapper = mount(PitchingChangeSelector, {
|
|
props: defaultProps,
|
|
})
|
|
|
|
const pitcherCards = wrapper.findAll('.reliever-card')
|
|
await pitcherCards[0].trigger('click')
|
|
await wrapper.vm.$nextTick()
|
|
|
|
const submitButton = wrapper.findAll('button').find(btn => btn.text().includes('Bring in Reliever'))
|
|
expect(submitButton?.attributes('disabled')).toBeUndefined()
|
|
})
|
|
|
|
it('disables submit when fatigued pitcher selected', async () => {
|
|
const mixedBench = [
|
|
createMockLineup(2, createMockPlayer(201, 'Fatigued Pitcher', ['P']), false, 'P', true),
|
|
]
|
|
|
|
const wrapper = mount(PitchingChangeSelector, {
|
|
props: {
|
|
...defaultProps,
|
|
benchPlayers: mixedBench,
|
|
},
|
|
})
|
|
|
|
// Try to select fatigued pitcher (should be prevented)
|
|
const pitcherCards = wrapper.findAll('.reliever-card')
|
|
await pitcherCards[0].trigger('click')
|
|
|
|
const submitButton = wrapper.findAll('button').find(btn => btn.text().includes('Bring in Reliever'))
|
|
expect(submitButton?.attributes('disabled')).toBeDefined()
|
|
})
|
|
|
|
it('disables submit when currentPitcher is null', () => {
|
|
const wrapper = mount(PitchingChangeSelector, {
|
|
props: {
|
|
...defaultProps,
|
|
currentPitcher: null,
|
|
},
|
|
})
|
|
|
|
const submitButton = wrapper.findAll('button').find(btn => btn.text().includes('Bring in Reliever'))
|
|
expect(submitButton?.attributes('disabled')).toBeDefined()
|
|
})
|
|
})
|
|
|
|
describe('Emit Events', () => {
|
|
it('emits submit event with correct payload', async () => {
|
|
const wrapper = mount(PitchingChangeSelector, {
|
|
props: defaultProps,
|
|
})
|
|
|
|
const pitcherCards = wrapper.findAll('.reliever-card')
|
|
await pitcherCards[0].trigger('click')
|
|
|
|
const submitButton = wrapper.find('.button-submit')
|
|
await submitButton.trigger('click')
|
|
|
|
expect(wrapper.emitted()).toHaveProperty('submit')
|
|
const submitEvents = wrapper.emitted('submit')
|
|
expect(submitEvents).toHaveLength(1)
|
|
expect(submitEvents![0]).toEqual([{
|
|
playerOutLineupId: 1,
|
|
playerInCardId: 201,
|
|
teamId: 1,
|
|
}])
|
|
})
|
|
|
|
it('emits cancel event when cancel clicked', async () => {
|
|
const wrapper = mount(PitchingChangeSelector, {
|
|
props: defaultProps,
|
|
})
|
|
|
|
const cancelButton = wrapper.find('.button-cancel')
|
|
await cancelButton.trigger('click')
|
|
|
|
expect(wrapper.emitted()).toHaveProperty('cancel')
|
|
expect(wrapper.emitted('cancel')).toHaveLength(1)
|
|
})
|
|
|
|
it('resets selection after submit', async () => {
|
|
const wrapper = mount(PitchingChangeSelector, {
|
|
props: defaultProps,
|
|
})
|
|
|
|
const pitcherCards = wrapper.findAll('.reliever-card')
|
|
await pitcherCards[0].trigger('click')
|
|
expect(wrapper.vm.selectedPitcherId).toBe(201)
|
|
|
|
const submitButton = wrapper.find('.button-submit')
|
|
await submitButton.trigger('click')
|
|
|
|
expect(wrapper.vm.selectedPitcherId).toBeNull()
|
|
})
|
|
|
|
it('resets selection after cancel', async () => {
|
|
const wrapper = mount(PitchingChangeSelector, {
|
|
props: defaultProps,
|
|
})
|
|
|
|
const pitcherCards = wrapper.findAll('.reliever-card')
|
|
await pitcherCards[0].trigger('click')
|
|
expect(wrapper.vm.selectedPitcherId).toBe(201)
|
|
|
|
const cancelButton = wrapper.find('.button-cancel')
|
|
await cancelButton.trigger('click')
|
|
|
|
expect(wrapper.vm.selectedPitcherId).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('Pitcher Metadata Display', () => {
|
|
it('displays pitcher role', () => {
|
|
const wrapper = mount(PitchingChangeSelector, {
|
|
props: defaultProps,
|
|
})
|
|
|
|
expect(wrapper.text()).toContain('Pitcher')
|
|
})
|
|
|
|
it('displays WARA stat when available', () => {
|
|
const wrapper = mount(PitchingChangeSelector, {
|
|
props: defaultProps,
|
|
})
|
|
|
|
expect(wrapper.text()).toContain('WARA: 1.5')
|
|
})
|
|
})
|
|
|
|
describe('Edge Cases', () => {
|
|
it('handles missing currentPitcher gracefully', () => {
|
|
const wrapper = mount(PitchingChangeSelector, {
|
|
props: {
|
|
...defaultProps,
|
|
currentPitcher: null,
|
|
},
|
|
})
|
|
|
|
expect(wrapper.text()).toContain('Bring in a relief pitcher to replace current pitcher')
|
|
})
|
|
|
|
it('prevents submit when currentPitcher is missing', async () => {
|
|
const wrapper = mount(PitchingChangeSelector, {
|
|
props: {
|
|
...defaultProps,
|
|
currentPitcher: null,
|
|
},
|
|
})
|
|
|
|
const pitcherCards = wrapper.findAll('.reliever-card')
|
|
if (pitcherCards.length > 0) {
|
|
await pitcherCards[0].trigger('click')
|
|
}
|
|
|
|
const submitButton = wrapper.findAll('button').find(btn => btn.text().includes('Bring in Reliever'))
|
|
if (submitButton) {
|
|
await submitButton.trigger('click')
|
|
}
|
|
|
|
expect(wrapper.emitted('submit')).toBeUndefined()
|
|
})
|
|
|
|
it('disables fatigued pitcher card with styling', () => {
|
|
const mixedBench = [
|
|
createMockLineup(2, createMockPlayer(201, 'Fatigued Pitcher', ['P']), false, 'P', true),
|
|
]
|
|
|
|
const wrapper = mount(PitchingChangeSelector, {
|
|
props: {
|
|
...defaultProps,
|
|
benchPlayers: mixedBench,
|
|
},
|
|
})
|
|
|
|
const pitcherCards = wrapper.findAll('.reliever-card')
|
|
expect(pitcherCards[0].classes()).toContain('reliever-fatigued')
|
|
expect(pitcherCards[0].attributes('disabled')).toBeDefined()
|
|
})
|
|
})
|
|
})
|