strat-gameplay-webapp/frontend-sba/tests/unit/components/Gameplay/DiceRoller.spec.ts
Cal Corum 2b8fea36a8 CLAUDE: Redesign dice display with team colors and consolidate player cards
Backend:
- Add home_team_dice_color and away_team_dice_color to GameState model
- Extract dice_color from game metadata in StateManager (default: cc0000)
- Add runners_on_base param to roll_ab for chaos check skipping

Frontend - Dice Display:
- Create DiceShapes.vue with SVG d6 (square) and d20 (hexagon) shapes
- Apply home team's dice_color to d6 dice, white for resolution d20
- Show chaos d20 in amber only when WP/PB check triggered
- Add automatic text contrast based on color luminance
- Reduce blank space and remove info bubble from dice results

Frontend - Player Cards:
- Consolidate pitcher/batter cards to single location below diamond
- Add active card highlighting based on dice roll (d6_one: 1-3=batter, 4-6=pitcher)
- New card header format: [Team] Position [Name] with full card image
- Remove redundant card displays from GameBoard and GameplayPanel
- Enlarge PlayerCardModal on desktop (max-w-3xl at 1024px+)

Tests:
- Add DiceShapes.spec.ts with 34 tests for color calculations and rendering
- Update DiceRoller.spec.ts for new DiceShapes integration
- Fix test_roll_dice_success for new runners_on_base parameter

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 00:16:32 -06:00

725 lines
21 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import DiceRoller from '~/components/Gameplay/DiceRoller.vue'
import DiceShapes from '~/components/Gameplay/DiceShapes.vue'
import type { RollData } from '~/types'
describe('DiceRoller', () => {
const createRollData = (overrides = {}): 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,
chaos_check_skipped: false,
timestamp: '2025-01-13T12:00:00Z',
...overrides,
})
const defaultProps = {
canRoll: true,
pendingRoll: null as RollData | null,
diceColor: 'cc0000', // Default red
}
beforeEach(() => {
vi.clearAllTimers()
vi.useFakeTimers()
})
// ============================================================================
// Rendering Tests
// ============================================================================
describe('Rendering', () => {
it('renders roll button when no pending roll', () => {
const wrapper = mount(DiceRoller, {
props: defaultProps,
})
expect(wrapper.find('.roll-button').exists()).toBe(true)
expect(wrapper.text()).toContain('Roll Dice')
expect(wrapper.find('.dice-results').exists()).toBe(false)
})
it('renders dice results when pending roll exists', () => {
const rollData = createRollData()
const wrapper = mount(DiceRoller, {
props: {
...defaultProps,
canRoll: false,
pendingRoll: rollData,
},
})
expect(wrapper.find('.roll-button').exists()).toBe(false)
expect(wrapper.find('.dice-results').exists()).toBe(true)
expect(wrapper.text()).toContain('Dice Results')
})
it('displays three dice when no WP/PB check triggered (chaos d20 hidden)', () => {
/**
* When chaos d20 doesn't trigger WP (1) or PB (2), it's hidden since values 3-20
* have no game effect. This reduces visual noise in the dice display.
*/
const rollData = createRollData({
d6_one: 5,
d6_two_total: 8,
chaos_d20: 17, // Not 1 or 2, so no check triggered
resolution_d20: 3,
check_wild_pitch: false,
check_passed_ball: false,
})
const wrapper = mount(DiceRoller, {
props: {
...defaultProps,
canRoll: false,
pendingRoll: rollData,
},
})
// DiceShapes components are rendered for each die
const diceComponents = wrapper.findAllComponents(DiceShapes)
expect(diceComponents).toHaveLength(3) // chaos d20 hidden when no check triggered
})
it('displays all four dice when wild pitch check triggered', () => {
/**
* Chaos d20 is shown when it triggers a Wild Pitch check (value == 1),
* since this affects gameplay and the user needs to see the dice value.
*/
const rollData = createRollData({
d6_one: 5,
d6_two_total: 8,
chaos_d20: 1, // Triggers WP check
resolution_d20: 3,
check_wild_pitch: true,
check_passed_ball: false,
})
const wrapper = mount(DiceRoller, {
props: {
...defaultProps,
canRoll: false,
pendingRoll: rollData,
},
})
const diceComponents = wrapper.findAllComponents(DiceShapes)
expect(diceComponents).toHaveLength(4) // chaos d20 shown for WP check
})
it('displays all four dice when passed ball check triggered', () => {
/**
* Chaos d20 is shown when it triggers a Passed Ball check (value == 2),
* since this affects gameplay and the user needs to see the dice value.
*/
const rollData = createRollData({
d6_one: 5,
d6_two_total: 8,
chaos_d20: 2, // Triggers PB check
resolution_d20: 3,
check_wild_pitch: false,
check_passed_ball: true,
})
const wrapper = mount(DiceRoller, {
props: {
...defaultProps,
canRoll: false,
pendingRoll: rollData,
},
})
const diceComponents = wrapper.findAllComponents(DiceShapes)
expect(diceComponents).toHaveLength(4) // chaos d20 shown for PB check
})
it('displays d6_two component dice values', () => {
const rollData = createRollData({
d6_two_a: 3,
d6_two_b: 5,
d6_two_total: 8,
})
const wrapper = mount(DiceRoller, {
props: {
...defaultProps,
canRoll: false,
pendingRoll: rollData,
},
})
expect(wrapper.text()).toContain('(3 + 5)')
})
})
// ============================================================================
// Button State Tests
// ============================================================================
describe('Button States', () => {
it('enables button when canRoll is true', () => {
const wrapper = mount(DiceRoller, {
props: defaultProps,
})
const button = wrapper.find('.roll-button')
expect(button.classes()).toContain('roll-button-enabled')
expect(button.classes()).not.toContain('roll-button-disabled')
expect(button.attributes('disabled')).toBeUndefined()
})
it('disables button when canRoll is false', () => {
const wrapper = mount(DiceRoller, {
props: {
...defaultProps,
canRoll: false,
},
})
const button = wrapper.find('.roll-button')
expect(button.classes()).toContain('roll-button-disabled')
expect(button.classes()).not.toContain('roll-button-enabled')
expect(button.attributes('disabled')).toBeDefined()
})
it('shows loading state when rolling', async () => {
const wrapper = mount(DiceRoller, {
props: defaultProps,
})
await wrapper.find('.roll-button').trigger('click')
expect(wrapper.text()).toContain('Rolling...')
expect(wrapper.find('.animate-spin').exists()).toBe(true)
})
it('disables button during rolling animation', async () => {
const wrapper = mount(DiceRoller, {
props: defaultProps,
})
await wrapper.find('.roll-button').trigger('click')
const button = wrapper.find('.roll-button')
expect(button.attributes('disabled')).toBeDefined()
})
it('resets rolling state after timeout', async () => {
const wrapper = mount(DiceRoller, {
props: defaultProps,
})
await wrapper.find('.roll-button').trigger('click')
expect(wrapper.text()).toContain('Rolling...')
vi.advanceTimersByTime(2000)
await wrapper.vm.$nextTick()
expect(wrapper.text()).not.toContain('Rolling...')
})
})
// ============================================================================
// Event Emission Tests
// ============================================================================
describe('Event Emission', () => {
it('emits roll event when button clicked', async () => {
const wrapper = mount(DiceRoller, {
props: defaultProps,
})
await wrapper.find('.roll-button').trigger('click')
expect(wrapper.emitted('roll')).toBeTruthy()
expect(wrapper.emitted('roll')).toHaveLength(1)
})
it('does not emit roll when canRoll is false', async () => {
const wrapper = mount(DiceRoller, {
props: {
...defaultProps,
canRoll: false,
},
})
await wrapper.find('.roll-button').trigger('click')
expect(wrapper.emitted('roll')).toBeFalsy()
})
it('does not emit roll during rolling animation', async () => {
const wrapper = mount(DiceRoller, {
props: defaultProps,
})
await wrapper.find('.roll-button').trigger('click')
await wrapper.find('.roll-button').trigger('click')
expect(wrapper.emitted('roll')).toHaveLength(1)
})
})
// ============================================================================
// Special Events Tests
// ============================================================================
describe('Special Events', () => {
it('shows wild pitch indicator when check_wild_pitch is true', () => {
const rollData = createRollData({ check_wild_pitch: true })
const wrapper = mount(DiceRoller, {
props: {
...defaultProps,
canRoll: false,
pendingRoll: rollData,
},
})
expect(wrapper.find('.wild-pitch').exists()).toBe(true)
expect(wrapper.text()).toContain('Wild Pitch Check')
})
it('shows passed ball indicator when check_passed_ball is true', () => {
const rollData = createRollData({ check_passed_ball: true })
const wrapper = mount(DiceRoller, {
props: {
...defaultProps,
canRoll: false,
pendingRoll: rollData,
},
})
expect(wrapper.find('.passed-ball').exists()).toBe(true)
expect(wrapper.text()).toContain('Passed Ball Check')
})
it('shows both indicators when both checks are true', () => {
const rollData = createRollData({
check_wild_pitch: true,
check_passed_ball: true,
})
const wrapper = mount(DiceRoller, {
props: {
...defaultProps,
canRoll: false,
pendingRoll: rollData,
},
})
expect(wrapper.find('.wild-pitch').exists()).toBe(true)
expect(wrapper.find('.passed-ball').exists()).toBe(true)
})
it('hides special events section when no checks are true', () => {
const rollData = createRollData({
check_wild_pitch: false,
check_passed_ball: false,
})
const wrapper = mount(DiceRoller, {
props: {
...defaultProps,
canRoll: false,
pendingRoll: rollData,
},
})
expect(wrapper.find('.special-events').exists()).toBe(false)
})
})
// ============================================================================
// Chaos d20 Conditional Display Tests
// ============================================================================
describe('Chaos d20 Conditional Display', () => {
/**
* The chaos d20 dice is only displayed when it triggers a Wild Pitch (1)
* or Passed Ball (2) check. Values 3-20 have no game effect and showing
* them creates visual noise. When bases are empty, the chaos check is
* skipped entirely since WP/PB is meaningless without runners.
*/
it('hides chaos d20 when no check triggered (values 3-20)', () => {
const rollData = createRollData({
chaos_d20: 15,
check_wild_pitch: false,
check_passed_ball: false,
chaos_check_skipped: false, // Runners on base, but roll was 3-20
})
const wrapper = mount(DiceRoller, {
props: {
...defaultProps,
canRoll: false,
pendingRoll: rollData,
},
})
const chaosItem = wrapper.find('.dice-chaos')
expect(chaosItem.exists()).toBe(false)
})
it('hides chaos d20 when chaos check was skipped (bases empty)', () => {
const rollData = createRollData({
chaos_d20: 1, // Would trigger WP but bases empty
check_wild_pitch: false, // Skipped due to no runners
check_passed_ball: false,
chaos_check_skipped: true, // No runners on base
})
const wrapper = mount(DiceRoller, {
props: {
...defaultProps,
canRoll: false,
pendingRoll: rollData,
},
})
const chaosItem = wrapper.find('.dice-chaos')
expect(chaosItem.exists()).toBe(false)
})
it('shows chaos d20 when wild pitch check triggered', () => {
const rollData = createRollData({
chaos_d20: 1,
check_wild_pitch: true,
check_passed_ball: false,
chaos_check_skipped: false,
})
const wrapper = mount(DiceRoller, {
props: {
...defaultProps,
canRoll: false,
pendingRoll: rollData,
},
})
const chaosItem = wrapper.find('.dice-chaos')
expect(chaosItem.exists()).toBe(true)
})
it('shows chaos d20 when passed ball check triggered', () => {
const rollData = createRollData({
chaos_d20: 2,
check_wild_pitch: false,
check_passed_ball: true,
chaos_check_skipped: false,
})
const wrapper = mount(DiceRoller, {
props: {
...defaultProps,
canRoll: false,
pendingRoll: rollData,
},
})
const chaosItem = wrapper.find('.dice-chaos')
expect(chaosItem.exists()).toBe(true)
})
it('displays correct chaos d20 value when shown', () => {
const rollData = createRollData({
chaos_d20: 1,
check_wild_pitch: true,
check_passed_ball: false,
})
const wrapper = mount(DiceRoller, {
props: {
...defaultProps,
canRoll: false,
pendingRoll: rollData,
},
})
const diceComponents = wrapper.findAllComponents(DiceShapes)
// Find the chaos d20 component (3rd one when WP/PB triggered)
const chaosDie = diceComponents[2]
expect(chaosDie.props('value')).toBe(1)
})
})
// ============================================================================
// Timestamp Formatting Tests
// ============================================================================
describe('Timestamp Formatting', () => {
it('formats timestamp correctly', () => {
const rollData = createRollData({
timestamp: '2025-01-13T14:30:45Z',
})
const wrapper = mount(DiceRoller, {
props: {
...defaultProps,
canRoll: false,
pendingRoll: rollData,
},
})
const timestampText = wrapper.find('.dice-header .text-sm').text()
expect(timestampText).toMatch(/\d{1,2}:\d{2}:\d{2}/)
})
})
// ============================================================================
// Dice Color Tests
// ============================================================================
describe('Dice Color', () => {
it('passes dice color to d6 DiceShapes components', () => {
const rollData = createRollData()
const wrapper = mount(DiceRoller, {
props: {
...defaultProps,
canRoll: false,
pendingRoll: rollData,
diceColor: '0066ff', // Blue
},
})
const diceComponents = wrapper.findAllComponents(DiceShapes)
// First two are d6 dice
expect(diceComponents[0].props('color')).toBe('0066ff')
expect(diceComponents[1].props('color')).toBe('0066ff')
})
it('uses white for resolution d20', () => {
const rollData = createRollData()
const wrapper = mount(DiceRoller, {
props: {
...defaultProps,
canRoll: false,
pendingRoll: rollData,
},
})
const diceComponents = wrapper.findAllComponents(DiceShapes)
// Last one is resolution d20 (when no chaos shown)
const resolutionD20 = diceComponents[diceComponents.length - 1]
expect(resolutionD20.props('color')).toBe('ffffff')
})
it('uses amber for chaos d20 when shown', () => {
const rollData = createRollData({
check_wild_pitch: true,
chaos_d20: 1,
})
const wrapper = mount(DiceRoller, {
props: {
...defaultProps,
canRoll: false,
pendingRoll: rollData,
},
})
const diceComponents = wrapper.findAllComponents(DiceShapes)
// Third one is chaos d20 when WP triggered
const chaosD20 = diceComponents[2]
expect(chaosD20.props('color')).toBe('f59e0b')
})
it('uses default red when no diceColor prop provided', () => {
const rollData = createRollData()
const wrapper = mount(DiceRoller, {
props: {
canRoll: false,
pendingRoll: rollData,
// No diceColor prop
},
})
const diceComponents = wrapper.findAllComponents(DiceShapes)
expect(diceComponents[0].props('color')).toBe('cc0000')
})
})
// ============================================================================
// Dice Type Tests
// ============================================================================
describe('Dice Types', () => {
it('renders d6 type for first two dice', () => {
const rollData = createRollData()
const wrapper = mount(DiceRoller, {
props: {
...defaultProps,
canRoll: false,
pendingRoll: rollData,
},
})
const diceComponents = wrapper.findAllComponents(DiceShapes)
expect(diceComponents[0].props('type')).toBe('d6')
expect(diceComponents[1].props('type')).toBe('d6')
})
it('renders d20 type for resolution die', () => {
const rollData = createRollData()
const wrapper = mount(DiceRoller, {
props: {
...defaultProps,
canRoll: false,
pendingRoll: rollData,
},
})
const diceComponents = wrapper.findAllComponents(DiceShapes)
// Last one is resolution d20
const resolutionD20 = diceComponents[diceComponents.length - 1]
expect(resolutionD20.props('type')).toBe('d20')
})
it('renders d20 type for chaos die when shown', () => {
const rollData = createRollData({
check_wild_pitch: true,
chaos_d20: 1,
})
const wrapper = mount(DiceRoller, {
props: {
...defaultProps,
canRoll: false,
pendingRoll: rollData,
},
})
const diceComponents = wrapper.findAllComponents(DiceShapes)
// Third one is chaos d20
expect(diceComponents[2].props('type')).toBe('d20')
})
})
// ============================================================================
// Layout Tests
// ============================================================================
describe('Layout', () => {
it('applies compact display class when chaos d20 is hidden', () => {
const rollData = createRollData() // Default: no WP/PB check
const wrapper = mount(DiceRoller, {
props: {
...defaultProps,
canRoll: false,
pendingRoll: rollData,
},
})
const diceDisplay = wrapper.find('.dice-display')
expect(diceDisplay.classes()).toContain('dice-display-compact')
})
it('does not apply compact display class when chaos d20 is shown', () => {
const rollData = createRollData({ check_wild_pitch: true, chaos_d20: 1 })
const wrapper = mount(DiceRoller, {
props: {
...defaultProps,
canRoll: false,
pendingRoll: rollData,
},
})
const diceDisplay = wrapper.find('.dice-display')
expect(diceDisplay.classes()).not.toContain('dice-display-compact')
})
})
// ============================================================================
// Edge Cases
// ============================================================================
describe('Edge Cases', () => {
it('handles maximum dice values', () => {
const rollData = createRollData({
d6_one: 6,
d6_two_a: 6,
d6_two_b: 6,
d6_two_total: 12,
chaos_d20: 20,
resolution_d20: 20,
})
const wrapper = mount(DiceRoller, {
props: {
...defaultProps,
canRoll: false,
pendingRoll: rollData,
},
})
const diceComponents = wrapper.findAllComponents(DiceShapes)
expect(diceComponents[0].props('value')).toBe(6)
expect(diceComponents[1].props('value')).toBe(12)
})
it('handles minimum dice values', () => {
const rollData = createRollData({
d6_one: 1,
d6_two_a: 1,
d6_two_b: 1,
d6_two_total: 2,
chaos_d20: 1,
resolution_d20: 1,
check_wild_pitch: true, // Show chaos d20
})
const wrapper = mount(DiceRoller, {
props: {
...defaultProps,
canRoll: false,
pendingRoll: rollData,
},
})
const diceComponents = wrapper.findAllComponents(DiceShapes)
expect(diceComponents[0].props('value')).toBe(1)
expect(diceComponents[1].props('value')).toBe(2)
expect(diceComponents[2].props('value')).toBe(1) // chaos d20
expect(diceComponents[3].props('value')).toBe(1) // resolution d20
})
it('transitions from no roll to roll result', async () => {
const wrapper = mount(DiceRoller, {
props: defaultProps,
})
expect(wrapper.find('.roll-button').exists()).toBe(true)
expect(wrapper.find('.dice-results').exists()).toBe(false)
await wrapper.setProps({ pendingRoll: createRollData(), canRoll: false })
expect(wrapper.find('.roll-button').exists()).toBe(false)
expect(wrapper.find('.dice-results').exists()).toBe(true)
})
it('clears roll result when pendingRoll set to null', async () => {
const wrapper = mount(DiceRoller, {
props: {
...defaultProps,
canRoll: false,
pendingRoll: createRollData(),
},
})
expect(wrapper.find('.dice-results').exists()).toBe(true)
await wrapper.setProps({ pendingRoll: null, canRoll: true })
expect(wrapper.find('.dice-results').exists()).toBe(false)
expect(wrapper.find('.roll-button').exists()).toBe(true)
})
})
})