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>
481 lines
14 KiB
TypeScript
481 lines
14 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
import { mount } from '@vue/test-utils'
|
|
import GameplayPanel from '~/components/Gameplay/GameplayPanel.vue'
|
|
import type { RollData, PlayResult } from '~/types'
|
|
import DiceRoller from '~/components/Gameplay/DiceRoller.vue'
|
|
import ManualOutcomeEntry from '~/components/Gameplay/ManualOutcomeEntry.vue'
|
|
import PlayResultComponent from '~/components/Gameplay/PlayResult.vue'
|
|
|
|
describe('GameplayPanel', () => {
|
|
const createRollData = (): RollData => ({
|
|
roll_id: 'test-roll-123',
|
|
d6_one: 3,
|
|
d6_two_a: 2,
|
|
d6_two_b: 4,
|
|
d6_two_total: 6,
|
|
chaos_d20: 15,
|
|
resolution_d20: 8,
|
|
check_wild_pitch: false,
|
|
check_passed_ball: false,
|
|
timestamp: '2025-01-13T12:00:00Z',
|
|
})
|
|
|
|
const createPlayResult = (): PlayResult => ({
|
|
play_number: 1,
|
|
outcome: 'STRIKEOUT',
|
|
description: 'Mike Trout strikes out swinging',
|
|
outs_recorded: 1,
|
|
runs_scored: 0,
|
|
runners_advanced: [],
|
|
batter_result: null,
|
|
new_state: {},
|
|
is_hit: false,
|
|
is_out: true,
|
|
is_walk: false,
|
|
is_strikeout: true,
|
|
})
|
|
|
|
const defaultProps = {
|
|
gameId: 'game-123',
|
|
isMyTurn: false,
|
|
canRollDice: false,
|
|
pendingRoll: null,
|
|
lastPlayResult: null,
|
|
canSubmitOutcome: false,
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllTimers()
|
|
})
|
|
|
|
// ============================================================================
|
|
// Rendering Tests
|
|
// ============================================================================
|
|
|
|
describe('Rendering', () => {
|
|
it('renders gameplay panel container', () => {
|
|
const wrapper = mount(GameplayPanel, {
|
|
props: defaultProps,
|
|
})
|
|
|
|
expect(wrapper.find('.gameplay-panel').exists()).toBe(true)
|
|
expect(wrapper.text()).toContain('Gameplay')
|
|
})
|
|
|
|
it('renders panel header with status', () => {
|
|
const wrapper = mount(GameplayPanel, {
|
|
props: defaultProps,
|
|
})
|
|
|
|
expect(wrapper.find('.panel-header').exists()).toBe(true)
|
|
expect(wrapper.find('.status-indicator').exists()).toBe(true)
|
|
expect(wrapper.find('.status-text').exists()).toBe(true)
|
|
})
|
|
})
|
|
|
|
// ============================================================================
|
|
// Workflow State: Idle Tests
|
|
// ============================================================================
|
|
|
|
describe('Workflow State: Idle', () => {
|
|
it('shows idle state when canRollDice is false', () => {
|
|
const wrapper = mount(GameplayPanel, {
|
|
props: {
|
|
...defaultProps,
|
|
canRollDice: false,
|
|
},
|
|
})
|
|
|
|
expect(wrapper.find('.state-idle').exists()).toBe(true)
|
|
expect(wrapper.text()).toContain('Waiting for strategic decisions')
|
|
})
|
|
|
|
it('displays idle status indicator', () => {
|
|
const wrapper = mount(GameplayPanel, {
|
|
props: defaultProps,
|
|
})
|
|
|
|
expect(wrapper.find('.status-idle').exists()).toBe(true)
|
|
expect(wrapper.find('.status-text').text()).toBe('Waiting')
|
|
})
|
|
})
|
|
|
|
// ============================================================================
|
|
// Workflow State: Ready to Roll Tests
|
|
// ============================================================================
|
|
|
|
describe('Workflow State: Ready to Roll', () => {
|
|
it('shows ready state when canRollDice is true and my turn', () => {
|
|
const wrapper = mount(GameplayPanel, {
|
|
props: {
|
|
...defaultProps,
|
|
canRollDice: true,
|
|
isMyTurn: true,
|
|
},
|
|
})
|
|
|
|
expect(wrapper.find('.state-ready').exists()).toBe(true)
|
|
expect(wrapper.text()).toContain('Your turn! Roll the dice')
|
|
})
|
|
|
|
it('shows waiting message when not my turn', () => {
|
|
const wrapper = mount(GameplayPanel, {
|
|
props: {
|
|
...defaultProps,
|
|
canRollDice: true,
|
|
isMyTurn: false,
|
|
},
|
|
})
|
|
|
|
expect(wrapper.text()).toContain('Waiting for opponent to roll dice')
|
|
})
|
|
|
|
it('renders DiceRoller component when my turn', () => {
|
|
const wrapper = mount(GameplayPanel, {
|
|
props: {
|
|
...defaultProps,
|
|
canRollDice: true,
|
|
isMyTurn: true,
|
|
},
|
|
})
|
|
|
|
expect(wrapper.findComponent(DiceRoller).exists()).toBe(true)
|
|
})
|
|
|
|
it('displays active status when ready and my turn', () => {
|
|
const wrapper = mount(GameplayPanel, {
|
|
props: {
|
|
...defaultProps,
|
|
canRollDice: true,
|
|
isMyTurn: true,
|
|
},
|
|
})
|
|
|
|
expect(wrapper.find('.status-active').exists()).toBe(true)
|
|
expect(wrapper.find('.status-text').text()).toBe('Your Turn')
|
|
})
|
|
|
|
it('displays opponent turn status when ready but not my turn', () => {
|
|
const wrapper = mount(GameplayPanel, {
|
|
props: {
|
|
...defaultProps,
|
|
canRollDice: true,
|
|
isMyTurn: false,
|
|
},
|
|
})
|
|
|
|
expect(wrapper.find('.status-text').text()).toBe('Opponent Turn')
|
|
})
|
|
})
|
|
|
|
// ============================================================================
|
|
// Workflow State: Rolled Tests
|
|
// ============================================================================
|
|
|
|
describe('Workflow State: Rolled', () => {
|
|
it('shows rolled state when pendingRoll exists', () => {
|
|
const wrapper = mount(GameplayPanel, {
|
|
props: {
|
|
...defaultProps,
|
|
pendingRoll: createRollData(),
|
|
canSubmitOutcome: true,
|
|
},
|
|
})
|
|
|
|
expect(wrapper.find('.state-rolled').exists()).toBe(true)
|
|
})
|
|
|
|
it('renders DiceRoller with roll results', () => {
|
|
const rollData = createRollData()
|
|
const wrapper = mount(GameplayPanel, {
|
|
props: {
|
|
...defaultProps,
|
|
pendingRoll: rollData,
|
|
canSubmitOutcome: true,
|
|
},
|
|
})
|
|
|
|
const diceRoller = wrapper.findComponent(DiceRoller)
|
|
expect(diceRoller.exists()).toBe(true)
|
|
expect(diceRoller.props('pendingRoll')).toEqual(rollData)
|
|
expect(diceRoller.props('canRoll')).toBe(false)
|
|
})
|
|
|
|
it('renders ManualOutcomeEntry component', () => {
|
|
const wrapper = mount(GameplayPanel, {
|
|
props: {
|
|
...defaultProps,
|
|
pendingRoll: createRollData(),
|
|
canSubmitOutcome: true,
|
|
},
|
|
})
|
|
|
|
expect(wrapper.findComponent(ManualOutcomeEntry).exists()).toBe(true)
|
|
})
|
|
|
|
it('displays active status when outcome entry active', () => {
|
|
const wrapper = mount(GameplayPanel, {
|
|
props: {
|
|
...defaultProps,
|
|
pendingRoll: createRollData(),
|
|
canSubmitOutcome: true,
|
|
},
|
|
})
|
|
|
|
expect(wrapper.find('.status-active').exists()).toBe(true)
|
|
expect(wrapper.find('.status-text').text()).toBe('Enter Outcome')
|
|
})
|
|
})
|
|
|
|
// ============================================================================
|
|
// Workflow State: Result Tests
|
|
// ============================================================================
|
|
|
|
describe('Workflow State: Result', () => {
|
|
it('shows result state when lastPlayResult exists', () => {
|
|
const wrapper = mount(GameplayPanel, {
|
|
props: {
|
|
...defaultProps,
|
|
lastPlayResult: createPlayResult(),
|
|
},
|
|
})
|
|
|
|
expect(wrapper.find('.state-result').exists()).toBe(true)
|
|
})
|
|
|
|
it('renders PlayResult component', () => {
|
|
const playResult = createPlayResult()
|
|
const wrapper = mount(GameplayPanel, {
|
|
props: {
|
|
...defaultProps,
|
|
lastPlayResult: playResult,
|
|
},
|
|
})
|
|
|
|
const playResultComponent = wrapper.findComponent(PlayResultComponent)
|
|
expect(playResultComponent.exists()).toBe(true)
|
|
expect(playResultComponent.props('result')).toEqual(playResult)
|
|
})
|
|
|
|
it('displays success status when result shown', () => {
|
|
const wrapper = mount(GameplayPanel, {
|
|
props: {
|
|
...defaultProps,
|
|
lastPlayResult: createPlayResult(),
|
|
},
|
|
})
|
|
|
|
expect(wrapper.find('.status-success').exists()).toBe(true)
|
|
expect(wrapper.find('.status-text').text()).toBe('Play Complete')
|
|
})
|
|
|
|
it('prioritizes result state over other states', () => {
|
|
const wrapper = mount(GameplayPanel, {
|
|
props: {
|
|
...defaultProps,
|
|
canRollDice: true,
|
|
pendingRoll: createRollData(),
|
|
lastPlayResult: createPlayResult(),
|
|
},
|
|
})
|
|
|
|
expect(wrapper.find('.state-result').exists()).toBe(true)
|
|
expect(wrapper.find('.state-rolled').exists()).toBe(false)
|
|
expect(wrapper.find('.state-ready').exists()).toBe(false)
|
|
})
|
|
})
|
|
|
|
// ============================================================================
|
|
// Event Emission Tests
|
|
// ============================================================================
|
|
|
|
describe('Event Emission', () => {
|
|
it('emits rollDice when DiceRoller emits roll', async () => {
|
|
const wrapper = mount(GameplayPanel, {
|
|
props: {
|
|
...defaultProps,
|
|
canRollDice: true,
|
|
isMyTurn: true,
|
|
},
|
|
})
|
|
|
|
const diceRoller = wrapper.findComponent(DiceRoller)
|
|
await diceRoller.vm.$emit('roll')
|
|
|
|
expect(wrapper.emitted('rollDice')).toBeTruthy()
|
|
expect(wrapper.emitted('rollDice')).toHaveLength(1)
|
|
})
|
|
|
|
it('emits submitOutcome when ManualOutcomeEntry submits', async () => {
|
|
const wrapper = mount(GameplayPanel, {
|
|
props: {
|
|
...defaultProps,
|
|
pendingRoll: createRollData(),
|
|
canSubmitOutcome: true,
|
|
},
|
|
})
|
|
|
|
const outcomeEntry = wrapper.findComponent(ManualOutcomeEntry)
|
|
const payload = { outcome: 'STRIKEOUT' as const, hitLocation: undefined }
|
|
await outcomeEntry.vm.$emit('submit', payload)
|
|
|
|
expect(wrapper.emitted('submitOutcome')).toBeTruthy()
|
|
expect(wrapper.emitted('submitOutcome')?.[0]).toEqual([payload])
|
|
})
|
|
|
|
it('emits dismissResult when PlayResult emits dismiss', async () => {
|
|
const wrapper = mount(GameplayPanel, {
|
|
props: {
|
|
...defaultProps,
|
|
lastPlayResult: createPlayResult(),
|
|
},
|
|
})
|
|
|
|
const playResult = wrapper.findComponent(PlayResultComponent)
|
|
await playResult.vm.$emit('dismiss')
|
|
|
|
expect(wrapper.emitted('dismissResult')).toBeTruthy()
|
|
expect(wrapper.emitted('dismissResult')).toHaveLength(1)
|
|
})
|
|
})
|
|
|
|
// ============================================================================
|
|
// Workflow State Transitions Tests
|
|
// ============================================================================
|
|
|
|
describe('Workflow State Transitions', () => {
|
|
it('transitions from idle to ready when canRollDice becomes true', async () => {
|
|
const wrapper = mount(GameplayPanel, {
|
|
props: defaultProps,
|
|
})
|
|
|
|
expect(wrapper.find('.state-idle').exists()).toBe(true)
|
|
|
|
await wrapper.setProps({ canRollDice: true, isMyTurn: true })
|
|
|
|
expect(wrapper.find('.state-idle').exists()).toBe(false)
|
|
expect(wrapper.find('.state-ready').exists()).toBe(true)
|
|
})
|
|
|
|
it('transitions from ready to rolled when pendingRoll set', async () => {
|
|
const wrapper = mount(GameplayPanel, {
|
|
props: {
|
|
...defaultProps,
|
|
canRollDice: true,
|
|
isMyTurn: true,
|
|
},
|
|
})
|
|
|
|
expect(wrapper.find('.state-ready').exists()).toBe(true)
|
|
|
|
await wrapper.setProps({
|
|
pendingRoll: createRollData(),
|
|
canRollDice: false,
|
|
canSubmitOutcome: true,
|
|
})
|
|
|
|
expect(wrapper.find('.state-ready').exists()).toBe(false)
|
|
expect(wrapper.find('.state-rolled').exists()).toBe(true)
|
|
})
|
|
|
|
it('transitions from rolled to result when lastPlayResult set', async () => {
|
|
const wrapper = mount(GameplayPanel, {
|
|
props: {
|
|
...defaultProps,
|
|
pendingRoll: createRollData(),
|
|
canSubmitOutcome: true,
|
|
},
|
|
})
|
|
|
|
expect(wrapper.find('.state-rolled').exists()).toBe(true)
|
|
|
|
await wrapper.setProps({
|
|
lastPlayResult: createPlayResult(),
|
|
pendingRoll: null,
|
|
})
|
|
|
|
expect(wrapper.find('.state-rolled').exists()).toBe(false)
|
|
expect(wrapper.find('.state-result').exists()).toBe(true)
|
|
})
|
|
|
|
it('transitions from result to idle when result dismissed', async () => {
|
|
const wrapper = mount(GameplayPanel, {
|
|
props: {
|
|
...defaultProps,
|
|
lastPlayResult: createPlayResult(),
|
|
},
|
|
})
|
|
|
|
expect(wrapper.find('.state-result').exists()).toBe(true)
|
|
|
|
await wrapper.setProps({ lastPlayResult: null })
|
|
|
|
expect(wrapper.find('.state-result').exists()).toBe(false)
|
|
expect(wrapper.find('.state-idle').exists()).toBe(true)
|
|
})
|
|
})
|
|
|
|
// ============================================================================
|
|
// Edge Cases
|
|
// ============================================================================
|
|
|
|
describe('Edge Cases', () => {
|
|
it('handles multiple rapid state changes', async () => {
|
|
const wrapper = mount(GameplayPanel, {
|
|
props: defaultProps,
|
|
})
|
|
|
|
await wrapper.setProps({ canRollDice: true, isMyTurn: true })
|
|
await wrapper.setProps({ pendingRoll: createRollData(), canSubmitOutcome: true })
|
|
await wrapper.setProps({ lastPlayResult: createPlayResult() })
|
|
|
|
expect(wrapper.find('.state-result').exists()).toBe(true)
|
|
})
|
|
|
|
it('handles missing gameId gracefully', () => {
|
|
const wrapper = mount(GameplayPanel, {
|
|
props: {
|
|
...defaultProps,
|
|
gameId: '',
|
|
},
|
|
})
|
|
|
|
expect(wrapper.find('.gameplay-panel').exists()).toBe(true)
|
|
})
|
|
|
|
it('handles all props being null/false', () => {
|
|
const wrapper = mount(GameplayPanel, {
|
|
props: {
|
|
gameId: 'test',
|
|
isMyTurn: false,
|
|
canRollDice: false,
|
|
pendingRoll: null,
|
|
lastPlayResult: null,
|
|
canSubmitOutcome: false,
|
|
},
|
|
})
|
|
|
|
expect(wrapper.find('.state-idle').exists()).toBe(true)
|
|
})
|
|
|
|
it('clears error when rolling dice', async () => {
|
|
const wrapper = mount(GameplayPanel, {
|
|
props: {
|
|
...defaultProps,
|
|
canRollDice: true,
|
|
isMyTurn: true,
|
|
},
|
|
})
|
|
|
|
// Manually set error (would normally come from failed operation)
|
|
wrapper.vm.error = 'Test error'
|
|
await wrapper.vm.$nextTick()
|
|
|
|
const diceRoller = wrapper.findComponent(DiceRoller)
|
|
await diceRoller.vm.$emit('roll')
|
|
|
|
expect(wrapper.vm.error).toBeNull()
|
|
})
|
|
})
|
|
})
|