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) }) }) })