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>
529 lines
15 KiB
TypeScript
529 lines
15 KiB
TypeScript
import { describe, it, expect } from 'vitest'
|
|
import { mount } from '@vue/test-utils'
|
|
import SubstitutionPanel from '~/components/Substitutions/SubstitutionPanel.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: 2.0,
|
|
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 = 'OF',
|
|
battingOrder: number | null = null
|
|
): Lineup {
|
|
return {
|
|
id,
|
|
game_id: 'test-game-123',
|
|
team_id: 1,
|
|
player_id: player.id,
|
|
position,
|
|
batting_order: battingOrder,
|
|
is_starter: true,
|
|
is_active: isActive,
|
|
entered_inning: 1,
|
|
replacing_id: null,
|
|
after_play: null,
|
|
is_fatigued: false,
|
|
player,
|
|
}
|
|
}
|
|
|
|
describe('SubstitutionPanel', () => {
|
|
const mockCurrentLineup = [
|
|
createMockLineup(1, createMockPlayer(101, 'John Pitcher', ['P']), true, 'P', null),
|
|
createMockLineup(2, createMockPlayer(102, 'Jane Catcher', ['C']), true, 'C', 1),
|
|
createMockLineup(3, createMockPlayer(103, 'Bob First', ['1B']), true, '1B', 2),
|
|
createMockLineup(4, createMockPlayer(104, 'Sally Second', ['2B']), true, '2B', 3),
|
|
]
|
|
|
|
const mockBenchPlayers = [
|
|
createMockLineup(5, createMockPlayer(201, 'Relief Pitcher', ['P']), false),
|
|
createMockLineup(6, createMockPlayer(202, 'Bench Hitter', ['OF']), false),
|
|
createMockLineup(7, createMockPlayer(203, 'Utility Player', ['2B', '3B', 'SS']), false),
|
|
]
|
|
|
|
const defaultProps = {
|
|
gameId: 'test-game-123',
|
|
teamId: 1,
|
|
currentLineup: mockCurrentLineup,
|
|
benchPlayers: mockBenchPlayers,
|
|
currentPitcher: mockCurrentLineup[0],
|
|
currentBatter: mockCurrentLineup[1],
|
|
}
|
|
|
|
describe('Rendering', () => {
|
|
it('renders component with header', () => {
|
|
const wrapper = mount(SubstitutionPanel, {
|
|
props: defaultProps,
|
|
})
|
|
|
|
expect(wrapper.text()).toContain('Substitutions')
|
|
})
|
|
|
|
it('renders all three tab buttons', () => {
|
|
const wrapper = mount(SubstitutionPanel, {
|
|
props: defaultProps,
|
|
})
|
|
|
|
expect(wrapper.text()).toContain('Pinch Hitter')
|
|
expect(wrapper.text()).toContain('Defensive Sub')
|
|
expect(wrapper.text()).toContain('Pitching Change')
|
|
})
|
|
|
|
it('shows idle state by default', () => {
|
|
const wrapper = mount(SubstitutionPanel, {
|
|
props: defaultProps,
|
|
})
|
|
|
|
expect(wrapper.text()).toContain('Select a substitution type above')
|
|
})
|
|
|
|
it('displays status indicator', () => {
|
|
const wrapper = mount(SubstitutionPanel, {
|
|
props: defaultProps,
|
|
})
|
|
|
|
const statusIndicator = wrapper.find('.status-indicator')
|
|
expect(statusIndicator.exists()).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('Tab Navigation', () => {
|
|
it('activates pinch hitter tab when clicked', async () => {
|
|
const wrapper = mount(SubstitutionPanel, {
|
|
props: defaultProps,
|
|
})
|
|
|
|
const tabs = wrapper.findAll('.tab-button')
|
|
const pinchHitterTab = tabs.find(tab => tab.text().includes('Pinch Hitter'))
|
|
await pinchHitterTab!.trigger('click')
|
|
|
|
expect(wrapper.vm.currentTab).toBe('pinch_hitter')
|
|
})
|
|
|
|
it('activates defensive replacement tab when clicked', async () => {
|
|
const wrapper = mount(SubstitutionPanel, {
|
|
props: defaultProps,
|
|
})
|
|
|
|
const tabs = wrapper.findAll('.tab-button')
|
|
const defensiveTab = tabs.find(tab => tab.text().includes('Defensive Sub'))
|
|
await defensiveTab!.trigger('click')
|
|
|
|
expect(wrapper.vm.currentTab).toBe('defensive_replacement')
|
|
})
|
|
|
|
it('activates pitching change tab when clicked', async () => {
|
|
const wrapper = mount(SubstitutionPanel, {
|
|
props: defaultProps,
|
|
})
|
|
|
|
const tabs = wrapper.findAll('.tab-button')
|
|
const pitchingTab = tabs.find(tab => tab.text().includes('Pitching Change'))
|
|
await pitchingTab!.trigger('click')
|
|
|
|
expect(wrapper.vm.currentTab).toBe('pitching_change')
|
|
})
|
|
|
|
it('highlights active tab', async () => {
|
|
const wrapper = mount(SubstitutionPanel, {
|
|
props: defaultProps,
|
|
})
|
|
|
|
const tabs = wrapper.findAll('.tab-button')
|
|
const pinchHitterTab = tabs.find(tab => tab.text().includes('Pinch Hitter'))
|
|
await pinchHitterTab!.trigger('click')
|
|
await wrapper.vm.$nextTick()
|
|
|
|
expect(pinchHitterTab!.classes()).toContain('tab-active')
|
|
})
|
|
|
|
it('allows switching between tabs', async () => {
|
|
const wrapper = mount(SubstitutionPanel, {
|
|
props: defaultProps,
|
|
})
|
|
|
|
const tabs = wrapper.findAll('.tab-button')
|
|
const pinchHitterTab = tabs.find(tab => tab.text().includes('Pinch Hitter'))
|
|
const pitchingTab = tabs.find(tab => tab.text().includes('Pitching Change'))
|
|
|
|
await pinchHitterTab!.trigger('click')
|
|
expect(wrapper.vm.currentTab).toBe('pinch_hitter')
|
|
|
|
await pitchingTab!.trigger('click')
|
|
expect(wrapper.vm.currentTab).toBe('pitching_change')
|
|
})
|
|
})
|
|
|
|
describe('Pinch Hitter Mode', () => {
|
|
it('renders PinchHitterSelector when tab selected', async () => {
|
|
const wrapper = mount(SubstitutionPanel, {
|
|
props: defaultProps,
|
|
})
|
|
|
|
const tabs = wrapper.findAll('.tab-button')
|
|
const pinchHitterTab = tabs.find(tab => tab.text().includes('Pinch Hitter'))
|
|
await pinchHitterTab!.trigger('click')
|
|
await wrapper.vm.$nextTick()
|
|
|
|
expect(wrapper.text()).toContain('Select a bench player to bat in place of the current batter')
|
|
})
|
|
|
|
it('shows instruction box for pinch hitter', async () => {
|
|
const wrapper = mount(SubstitutionPanel, {
|
|
props: defaultProps,
|
|
})
|
|
|
|
const tabs = wrapper.findAll('.tab-button')
|
|
const pinchHitterTab = tabs.find(tab => tab.text().includes('Pinch Hitter'))
|
|
await pinchHitterTab!.trigger('click')
|
|
await wrapper.vm.$nextTick()
|
|
|
|
const instructionBox = wrapper.find('.instruction-box')
|
|
expect(instructionBox.exists()).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('Defensive Replacement Mode', () => {
|
|
it('renders DefensiveReplacementSelector when tab selected', async () => {
|
|
const wrapper = mount(SubstitutionPanel, {
|
|
props: defaultProps,
|
|
})
|
|
|
|
const tabs = wrapper.findAll('.tab-button')
|
|
const defensiveTab = tabs.find(tab => tab.text().includes('Defensive Sub'))
|
|
await defensiveTab!.trigger('click')
|
|
await wrapper.vm.$nextTick()
|
|
|
|
expect(wrapper.text()).toContain('Replace a defensive player with a bench player')
|
|
})
|
|
|
|
it('shows active fielders for selection', async () => {
|
|
const wrapper = mount(SubstitutionPanel, {
|
|
props: defaultProps,
|
|
})
|
|
|
|
const tabs = wrapper.findAll('.tab-button')
|
|
const defensiveTab = tabs.find(tab => tab.text().includes('Defensive Sub'))
|
|
await defensiveTab!.trigger('click')
|
|
await wrapper.vm.$nextTick()
|
|
|
|
expect(wrapper.text()).toContain('Select player to replace:')
|
|
expect(wrapper.text()).toContain('Jane Catcher')
|
|
expect(wrapper.text()).toContain('Bob First')
|
|
})
|
|
|
|
it('excludes pitcher from defensive replacement selection', async () => {
|
|
const wrapper = mount(SubstitutionPanel, {
|
|
props: defaultProps,
|
|
})
|
|
|
|
const tabs = wrapper.findAll('.tab-button')
|
|
const defensiveTab = tabs.find(tab => tab.text().includes('Defensive Sub'))
|
|
await defensiveTab!.trigger('click')
|
|
await wrapper.vm.$nextTick()
|
|
|
|
expect(wrapper.text()).not.toContain('John Pitcher')
|
|
})
|
|
})
|
|
|
|
describe('Pitching Change Mode', () => {
|
|
it('renders PitchingChangeSelector when tab selected', async () => {
|
|
const wrapper = mount(SubstitutionPanel, {
|
|
props: defaultProps,
|
|
})
|
|
|
|
const tabs = wrapper.findAll('.tab-button')
|
|
const pitchingTab = tabs.find(tab => tab.text().includes('Pitching Change'))
|
|
await pitchingTab!.trigger('click')
|
|
await wrapper.vm.$nextTick()
|
|
|
|
expect(wrapper.text()).toContain('Bring in a relief pitcher from the bench')
|
|
})
|
|
})
|
|
|
|
describe('Event Emissions', () => {
|
|
it('emits pinchHitter event with correct payload', async () => {
|
|
const wrapper = mount(SubstitutionPanel, {
|
|
props: defaultProps,
|
|
})
|
|
|
|
const tabs = wrapper.findAll('.tab-button')
|
|
const pinchHitterTab = tabs.find(tab => tab.text().includes('Pinch Hitter'))
|
|
await pinchHitterTab!.trigger('click')
|
|
|
|
const testPayload = {
|
|
playerOutLineupId: 1,
|
|
playerInCardId: 201,
|
|
teamId: 1,
|
|
}
|
|
|
|
// Manually trigger the handler
|
|
wrapper.vm.handlePinchHitterSubmit(testPayload)
|
|
await wrapper.vm.$nextTick()
|
|
|
|
expect(wrapper.emitted()).toHaveProperty('pinchHitter')
|
|
expect(wrapper.emitted('pinchHitter')![0]).toEqual([testPayload])
|
|
})
|
|
|
|
it('emits defensiveReplacement event with correct payload', async () => {
|
|
const wrapper = mount(SubstitutionPanel, {
|
|
props: defaultProps,
|
|
})
|
|
|
|
const testPayload = {
|
|
playerOutLineupId: 2,
|
|
playerInCardId: 203,
|
|
newPosition: '2B',
|
|
teamId: 1,
|
|
}
|
|
|
|
wrapper.vm.handleDefensiveReplacementSubmit(testPayload)
|
|
await wrapper.vm.$nextTick()
|
|
|
|
expect(wrapper.emitted()).toHaveProperty('defensiveReplacement')
|
|
expect(wrapper.emitted('defensiveReplacement')![0]).toEqual([testPayload])
|
|
})
|
|
|
|
it('emits pitchingChange event with correct payload', async () => {
|
|
const wrapper = mount(SubstitutionPanel, {
|
|
props: defaultProps,
|
|
})
|
|
|
|
const testPayload = {
|
|
playerOutLineupId: 1,
|
|
playerInCardId: 201,
|
|
teamId: 1,
|
|
}
|
|
|
|
wrapper.vm.handlePitchingChangeSubmit(testPayload)
|
|
await wrapper.vm.$nextTick()
|
|
|
|
expect(wrapper.emitted()).toHaveProperty('pitchingChange')
|
|
expect(wrapper.emitted('pitchingChange')![0]).toEqual([testPayload])
|
|
})
|
|
|
|
it('emits cancel event when selector cancelled', async () => {
|
|
const wrapper = mount(SubstitutionPanel, {
|
|
props: defaultProps,
|
|
})
|
|
|
|
wrapper.vm.handleCancel()
|
|
await wrapper.vm.$nextTick()
|
|
|
|
expect(wrapper.emitted()).toHaveProperty('cancel')
|
|
})
|
|
})
|
|
|
|
describe('Status Management', () => {
|
|
it('shows active status when tab selected', async () => {
|
|
const wrapper = mount(SubstitutionPanel, {
|
|
props: defaultProps,
|
|
})
|
|
|
|
const tabs = wrapper.findAll('.tab-button')
|
|
const pinchHitterTab = tabs.find(tab => tab.text().includes('Pinch Hitter'))
|
|
await pinchHitterTab!.trigger('click')
|
|
await wrapper.vm.$nextTick()
|
|
|
|
expect(wrapper.vm.statusClass).toBe('status-active')
|
|
expect(wrapper.vm.statusText).toBe('Active')
|
|
})
|
|
|
|
it('shows success status after successful submission', async () => {
|
|
const wrapper = mount(SubstitutionPanel, {
|
|
props: defaultProps,
|
|
})
|
|
|
|
const testPayload = {
|
|
playerOutLineupId: 1,
|
|
playerInCardId: 201,
|
|
teamId: 1,
|
|
}
|
|
|
|
wrapper.vm.handlePinchHitterSubmit(testPayload)
|
|
await wrapper.vm.$nextTick()
|
|
|
|
expect(wrapper.vm.statusClass).toBe('status-success')
|
|
expect(wrapper.vm.statusText).toBe('Success')
|
|
})
|
|
|
|
it('shows idle status by default', () => {
|
|
const wrapper = mount(SubstitutionPanel, {
|
|
props: defaultProps,
|
|
})
|
|
|
|
expect(wrapper.vm.statusClass).toBe('status-idle')
|
|
expect(wrapper.vm.statusText).toBe('Idle')
|
|
})
|
|
})
|
|
|
|
describe('Success Messages', () => {
|
|
it('displays success message after pinch hitter submission', async () => {
|
|
const wrapper = mount(SubstitutionPanel, {
|
|
props: defaultProps,
|
|
})
|
|
|
|
const testPayload = {
|
|
playerOutLineupId: 1,
|
|
playerInCardId: 201,
|
|
teamId: 1,
|
|
}
|
|
|
|
wrapper.vm.handlePinchHitterSubmit(testPayload)
|
|
await wrapper.vm.$nextTick()
|
|
|
|
expect(wrapper.text()).toContain('Pinch hitter substitution submitted')
|
|
})
|
|
|
|
it('displays success message after defensive replacement', async () => {
|
|
const wrapper = mount(SubstitutionPanel, {
|
|
props: defaultProps,
|
|
})
|
|
|
|
const testPayload = {
|
|
playerOutLineupId: 2,
|
|
playerInCardId: 203,
|
|
newPosition: '2B',
|
|
teamId: 1,
|
|
}
|
|
|
|
wrapper.vm.handleDefensiveReplacementSubmit(testPayload)
|
|
await wrapper.vm.$nextTick()
|
|
|
|
expect(wrapper.text()).toContain('Defensive replacement submitted')
|
|
})
|
|
|
|
it('displays success message after pitching change', async () => {
|
|
const wrapper = mount(SubstitutionPanel, {
|
|
props: defaultProps,
|
|
})
|
|
|
|
const testPayload = {
|
|
playerOutLineupId: 1,
|
|
playerInCardId: 201,
|
|
teamId: 1,
|
|
}
|
|
|
|
wrapper.vm.handlePitchingChangeSubmit(testPayload)
|
|
await wrapper.vm.$nextTick()
|
|
|
|
expect(wrapper.text()).toContain('Pitching change submitted')
|
|
})
|
|
})
|
|
|
|
describe('State Reset', () => {
|
|
it('resets to idle after cancel', async () => {
|
|
const wrapper = mount(SubstitutionPanel, {
|
|
props: defaultProps,
|
|
})
|
|
|
|
const tabs = wrapper.findAll('.tab-button')
|
|
const pinchHitterTab = tabs.find(tab => tab.text().includes('Pinch Hitter'))
|
|
await pinchHitterTab!.trigger('click')
|
|
expect(wrapper.vm.currentTab).toBe('pinch_hitter')
|
|
|
|
wrapper.vm.handleCancel()
|
|
await wrapper.vm.$nextTick()
|
|
|
|
expect(wrapper.vm.currentTab).toBeNull()
|
|
})
|
|
|
|
it('clears error when changing tabs', async () => {
|
|
const wrapper = mount(SubstitutionPanel, {
|
|
props: defaultProps,
|
|
})
|
|
|
|
wrapper.vm.error = 'Test error'
|
|
await wrapper.vm.$nextTick()
|
|
|
|
const tabs = wrapper.findAll('.tab-button')
|
|
const pinchHitterTab = tabs.find(tab => tab.text().includes('Pinch Hitter'))
|
|
await pinchHitterTab!.trigger('click')
|
|
|
|
expect(wrapper.vm.error).toBeNull()
|
|
})
|
|
|
|
it('clears defensive player selection when switching away from defensive tab', async () => {
|
|
const wrapper = mount(SubstitutionPanel, {
|
|
props: defaultProps,
|
|
})
|
|
|
|
wrapper.vm.selectedDefensivePlayer = mockCurrentLineup[1]
|
|
await wrapper.vm.$nextTick()
|
|
|
|
const tabs = wrapper.findAll('.tab-button')
|
|
const pinchHitterTab = tabs.find(tab => tab.text().includes('Pinch Hitter'))
|
|
await pinchHitterTab!.trigger('click')
|
|
|
|
expect(wrapper.vm.selectedDefensivePlayer).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('Edge Cases', () => {
|
|
it('handles empty bench players gracefully', () => {
|
|
const wrapper = mount(SubstitutionPanel, {
|
|
props: {
|
|
...defaultProps,
|
|
benchPlayers: [],
|
|
},
|
|
})
|
|
|
|
expect(wrapper.exists()).toBe(true)
|
|
})
|
|
|
|
it('handles missing currentPitcher', () => {
|
|
const wrapper = mount(SubstitutionPanel, {
|
|
props: {
|
|
...defaultProps,
|
|
currentPitcher: null,
|
|
},
|
|
})
|
|
|
|
expect(wrapper.exists()).toBe(true)
|
|
})
|
|
|
|
it('handles missing currentBatter', () => {
|
|
const wrapper = mount(SubstitutionPanel, {
|
|
props: {
|
|
...defaultProps,
|
|
currentBatter: null,
|
|
},
|
|
})
|
|
|
|
expect(wrapper.exists()).toBe(true)
|
|
})
|
|
})
|
|
})
|