Complete TEST-017, TEST-018, TEST-019 - 138 new tests

Add comprehensive test coverage for drag/drop, deck builder components, and pages:

TEST-017: Drag/drop edge cases (17 tests)
- Expand useDragDrop.spec.ts with edge case coverage
- DataTransfer fallback, touch events, invalid JSON handling
- Multiple drop targets and validation rules

TEST-018: Deck builder edge cases (75 tests)
- DeckActionButtons.spec.ts: save/cancel states, validation (19 tests)
- DeckHeader.spec.ts: name input, special chars, rapid typing (18 tests)
- DeckCardRow.spec.ts: quantity stepper, drag/drop integration (38 tests)

TEST-019: Page tests (44 tests)
- HomePage.spec.ts: auth states, navigation, accessibility (18 tests)
- CampaignPage.spec.ts: placeholder rendering, layout (8 tests)
- MatchPage.spec.ts: connection states, routing, cleanup (18 tests)

All 138 tests passing. Week 5 testing backlog complete.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2026-02-03 10:12:29 -06:00
parent c5aef933e2
commit 63bcff8d9f
8 changed files with 3098 additions and 4 deletions

View File

@ -331,10 +331,10 @@ The Mantimon TCG frontend has **excellent test discipline** (1000 passing tests)
**Theme:** Pages and Integration Testing
**Tasks:**
- [ ] TEST-016: User store edge cases *(3h)*
- [ ] TEST-017: Drag/drop edge cases *(3h)*
- [ ] TEST-018: Deck builder edge cases *(4h)*
- [ ] TEST-019: Page tests (Home, Campaign, Match) *(6h)*
- [x] TEST-016: User store edge cases *(3h)***COMPLETE** (20 tests)
- [x] TEST-017: Drag/drop edge cases *(3h)***COMPLETE** (17 edge case tests added)
- [x] TEST-018: Deck builder edge cases *(4h)***COMPLETE** (75 tests: DeckActionButtons, DeckHeader, DeckCardRow)
- [x] TEST-019: Page tests (Home, Campaign, Match) *(6h)***COMPLETE** (44 tests: HomePage, CampaignPage, MatchPage)
- [ ] TEST-021: Animation sequences *(6h)*
**Deliverables:**

View File

@ -0,0 +1,434 @@
/**
* Tests for DeckActionButtons component.
*
* These tests verify the save/cancel buttons work correctly with
* proper disabled states and loading indicators.
*/
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import DeckActionButtons from './DeckActionButtons.vue'
describe('DeckActionButtons', () => {
describe('initial state', () => {
it('renders save and cancel buttons', () => {
/**
* Test that both buttons are rendered.
*
* Users need both save and cancel options when building decks.
*/
const wrapper = mount(DeckActionButtons, {
props: {
canSave: true,
isSaving: false,
isDirty: true,
deckName: 'Test Deck',
},
})
expect(wrapper.text()).toContain('Save')
expect(wrapper.text()).toContain('Cancel')
})
})
describe('save button states', () => {
it('enables save button when deck has name and is dirty', () => {
/**
* Test save button is enabled when valid.
*
* Users should be able to save when the deck has a name
* and has unsaved changes.
*/
const wrapper = mount(DeckActionButtons, {
props: {
canSave: true,
isSaving: false,
isDirty: true,
deckName: 'Test Deck',
},
})
const saveButton = wrapper.findAll('button').find(b => b.text().includes('Save'))
expect(saveButton?.attributes('disabled')).toBeUndefined()
})
it('disables save button when deck name is empty', () => {
/**
* Test validation edge case: empty deck name.
*
* Users cannot save a deck without a name - this prevents
* creating unnamed decks that are hard to identify later.
*/
const wrapper = mount(DeckActionButtons, {
props: {
canSave: true,
isSaving: false,
isDirty: true,
deckName: '',
},
})
const saveButton = wrapper.findAll('button').find(b => b.text().includes('Save'))
expect(saveButton?.attributes('disabled')).toBeDefined()
})
it('disables save button when deck name is only whitespace', () => {
/**
* Test validation edge case: whitespace-only name.
*
* Names that are just spaces are not valid - they should
* be treated the same as empty names.
*/
const wrapper = mount(DeckActionButtons, {
props: {
canSave: true,
isSaving: false,
isDirty: true,
deckName: ' ',
},
})
const saveButton = wrapper.findAll('button').find(b => b.text().includes('Save'))
expect(saveButton?.attributes('disabled')).toBeDefined()
})
it('disables save button when not dirty', () => {
/**
* Test save button disabled when no changes.
*
* Users shouldn't save if nothing has changed - prevents
* unnecessary API calls and database writes.
*/
const wrapper = mount(DeckActionButtons, {
props: {
canSave: true,
isSaving: false,
isDirty: false,
deckName: 'Test Deck',
},
})
const saveButton = wrapper.findAll('button').find(b => b.text().includes('Save'))
expect(saveButton?.attributes('disabled')).toBeDefined()
})
it('disables save button when saving is in progress', () => {
/**
* Test save button disabled during save.
*
* Prevents double-submits and race conditions by disabling
* the button while a save is in progress.
*/
const wrapper = mount(DeckActionButtons, {
props: {
canSave: true,
isSaving: true,
isDirty: true,
deckName: 'Test Deck',
},
})
const saveButton = wrapper.findAll('button').find(b => b.text().includes('Saving'))
expect(saveButton?.attributes('disabled')).toBeDefined()
})
it('shows "Saving..." text with spinner when saving', () => {
/**
* Test loading state visual feedback.
*
* Users need to see that their save is in progress so they
* don't think the button is broken or click it again.
*/
const wrapper = mount(DeckActionButtons, {
props: {
canSave: true,
isSaving: true,
isDirty: true,
deckName: 'Test Deck',
},
})
expect(wrapper.text()).toContain('Saving...')
// Spinner SVG should be present
const svg = wrapper.find('svg')
expect(svg.exists()).toBe(true)
expect(svg.classes()).toContain('animate-spin')
})
it('shows "Save" text when not saving', () => {
/**
* Test default button text.
*
* Normal state should show simple "Save" text.
*/
const wrapper = mount(DeckActionButtons, {
props: {
canSave: true,
isSaving: false,
isDirty: true,
deckName: 'Test Deck',
},
})
const saveButton = wrapper.findAll('button').find(b => b.text() === 'Save')
expect(saveButton).toBeDefined()
})
})
describe('cancel button states', () => {
it('enables cancel button when not saving', () => {
/**
* Test cancel button is always enabled when idle.
*
* Users should be able to cancel at any time unless a save
* is in progress.
*/
const wrapper = mount(DeckActionButtons, {
props: {
canSave: false,
isSaving: false,
isDirty: false,
deckName: '',
},
})
const cancelButton = wrapper.findAll('button').find(b => b.text().includes('Cancel'))
expect(cancelButton?.attributes('disabled')).toBeUndefined()
})
it('disables cancel button when saving', () => {
/**
* Test cancel disabled during save.
*
* Users shouldn't cancel mid-save as it could leave the deck
* in an inconsistent state.
*/
const wrapper = mount(DeckActionButtons, {
props: {
canSave: true,
isSaving: true,
isDirty: true,
deckName: 'Test Deck',
},
})
const cancelButton = wrapper.findAll('button').find(b => b.text().includes('Cancel'))
expect(cancelButton?.attributes('disabled')).toBeDefined()
})
})
describe('event emission', () => {
it('emits save event when save button clicked', async () => {
/**
* Test save event emission.
*
* Clicking the save button should notify the parent to
* trigger the save operation.
*/
const wrapper = mount(DeckActionButtons, {
props: {
canSave: true,
isSaving: false,
isDirty: true,
deckName: 'Test Deck',
},
})
const saveButton = wrapper.findAll('button').find(b => b.text().includes('Save'))
await saveButton?.trigger('click')
expect(wrapper.emitted('save')).toBeTruthy()
expect(wrapper.emitted('save')?.length).toBe(1)
})
it('emits cancel event when cancel button clicked', async () => {
/**
* Test cancel event emission.
*
* Clicking the cancel button should notify the parent to
* navigate away or show confirmation dialog.
*/
const wrapper = mount(DeckActionButtons, {
props: {
canSave: false,
isSaving: false,
isDirty: false,
deckName: '',
},
})
const cancelButton = wrapper.findAll('button').find(b => b.text().includes('Cancel'))
await cancelButton?.trigger('click')
expect(wrapper.emitted('cancel')).toBeTruthy()
expect(wrapper.emitted('cancel')?.length).toBe(1)
})
it('does not emit save when button is disabled', async () => {
/**
* Test disabled button doesn't emit.
*
* Even if a disabled button is somehow clicked (via code),
* it shouldn't emit the event.
*/
const wrapper = mount(DeckActionButtons, {
props: {
canSave: false,
isSaving: false,
isDirty: false,
deckName: '',
},
})
const saveButton = wrapper.findAll('button').find(b => b.text().includes('Save'))
// Try to trigger even though disabled
await saveButton?.trigger('click')
// Native disabled attribute prevents click, but if it somehow fires, no emit should occur
// Since the button has disabled attribute, the click won't fire
expect(saveButton?.attributes('disabled')).toBeDefined()
})
})
describe('edge cases', () => {
it('handles rapid save clicks gracefully', async () => {
/**
* Test double-click protection.
*
* If a user clicks save multiple times quickly, only one
* save event should be emitted before the button disables.
*/
const wrapper = mount(DeckActionButtons, {
props: {
canSave: true,
isSaving: false,
isDirty: true,
deckName: 'Test Deck',
},
})
const saveButton = wrapper.findAll('button').find(b => b.text().includes('Save'))
// Click once
await saveButton?.trigger('click')
expect(wrapper.emitted('save')?.length).toBe(1)
// Try to click again (parent should have set isSaving=true by now)
await wrapper.setProps({ isSaving: true })
await saveButton?.trigger('click')
// Should still only have one emit (second click didn't fire because button is disabled)
expect(wrapper.emitted('save')?.length).toBe(1)
})
it('handles all disabled conditions at once', () => {
/**
* Test worst-case scenario: everything disabled.
*
* When saving, no name, and not dirty, the save button
* should be disabled for multiple reasons.
*/
const wrapper = mount(DeckActionButtons, {
props: {
canSave: false,
isSaving: true,
isDirty: false,
deckName: '',
},
})
const saveButton = wrapper.findAll('button').find(b => b.text().includes('Saving'))
expect(saveButton?.attributes('disabled')).toBeDefined()
})
it('re-enables save button after save completes', async () => {
/**
* Test state recovery after save.
*
* After a successful save, the button should become disabled
* again (because isDirty becomes false), not remain in saving state.
*/
const wrapper = mount(DeckActionButtons, {
props: {
canSave: true,
isSaving: true,
isDirty: true,
deckName: 'Test Deck',
},
})
// Save completes, deck is no longer dirty
await wrapper.setProps({ isSaving: false, isDirty: false })
const saveButton = wrapper.findAll('button').find(b => b.text().includes('Save'))
// Should be disabled because not dirty
expect(saveButton?.attributes('disabled')).toBeDefined()
// Should show "Save" not "Saving..."
expect(wrapper.text()).not.toContain('Saving...')
})
it('handles deck name with special characters', () => {
/**
* Test deck names with special characters.
*
* Names with emojis, unicode, or special characters should
* not cause issues with the trim() validation.
*/
const wrapper = mount(DeckActionButtons, {
props: {
canSave: true,
isSaving: false,
isDirty: true,
deckName: '🔥 Fire Deck! 🔥',
},
})
const saveButton = wrapper.findAll('button').find(b => b.text().includes('Save'))
expect(saveButton?.attributes('disabled')).toBeUndefined()
})
})
describe('accessibility', () => {
it('save button has type="button"', () => {
/**
* Test button type attribute.
*
* Buttons should have type="button" to prevent accidental
* form submission in parent contexts.
*/
const wrapper = mount(DeckActionButtons, {
props: {
canSave: true,
isSaving: false,
isDirty: true,
deckName: 'Test Deck',
},
})
const buttons = wrapper.findAll('button')
buttons.forEach(button => {
expect(button.attributes('type')).toBe('button')
})
})
it('spinner has aria-hidden', () => {
/**
* Test spinner accessibility.
*
* Loading spinners should be hidden from screen readers
* since the "Saving..." text provides the same information.
*/
const wrapper = mount(DeckActionButtons, {
props: {
canSave: true,
isSaving: true,
isDirty: true,
deckName: 'Test Deck',
},
})
const svg = wrapper.find('svg')
expect(svg.attributes('aria-hidden')).toBe('true')
})
})
})

View File

@ -0,0 +1,812 @@
/**
* Tests for DeckCardRow component.
*
* These tests verify the card row displays correctly with quantity
* stepper, drag-drop support, and interaction edge cases.
*/
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import DeckCardRow from './DeckCardRow.vue'
import type { CardDefinition, EnergyType } from '@/types'
/**
* Helper to create a mock card definition.
*/
function createMockCard(overrides: Partial<CardDefinition> = {}): CardDefinition {
return {
id: 'test-card',
name: 'Pikachu',
category: 'pokemon',
type: 'lightning',
hp: 60,
imageUrl: '/images/pikachu.png',
rarity: 'common',
setId: 'base',
setNumber: 25,
...overrides,
}
}
describe('DeckCardRow', () => {
describe('rendering', () => {
it('renders card name', () => {
/**
* Test card name display.
*
* Users need to see the card name to identify cards in
* their deck.
*/
const card = createMockCard({ name: 'Pikachu' })
const wrapper = mount(DeckCardRow, {
props: {
card,
quantity: 1,
},
})
expect(wrapper.text()).toContain('Pikachu')
})
it('renders card thumbnail when imageUrl exists', () => {
/**
* Test card image rendering.
*
* Cards with images should display a thumbnail.
*/
const card = createMockCard({ imageUrl: '/images/pikachu.png' })
const wrapper = mount(DeckCardRow, {
props: {
card,
quantity: 1,
},
})
const img = wrapper.find('img')
expect(img.exists()).toBe(true)
expect(img.attributes('src')).toBe('/images/pikachu.png')
expect(img.attributes('alt')).toBe('Pikachu')
})
it('shows placeholder icon when no imageUrl', () => {
/**
* Test fallback for missing images.
*
* Cards without images should show a placeholder icon
* instead of a broken image.
*/
const card = createMockCard({ imageUrl: undefined })
const wrapper = mount(DeckCardRow, {
props: {
card,
quantity: 1,
},
})
// Should show SVG placeholder
const svg = wrapper.find('svg')
expect(svg.exists()).toBe(true)
})
it('displays HP for Pokemon cards', () => {
/**
* Test HP display for Pokemon.
*
* Pokemon cards should show their HP value.
*/
const card = createMockCard({ category: 'pokemon', hp: 60 })
const wrapper = mount(DeckCardRow, {
props: {
card,
quantity: 1,
},
})
expect(wrapper.text()).toContain('60 HP')
})
it('does not display HP for non-Pokemon cards', () => {
/**
* Test HP hidden for trainer cards.
*
* Trainer and energy cards don't have HP and shouldn't
* display it.
*/
const card = createMockCard({ category: 'trainer', hp: undefined })
const wrapper = mount(DeckCardRow, {
props: {
card,
quantity: 1,
},
})
expect(wrapper.text()).not.toContain('HP')
})
it('displays current quantity', () => {
/**
* Test quantity display.
*
* Users need to see how many copies are in the deck.
*/
const card = createMockCard()
const wrapper = mount(DeckCardRow, {
props: {
card,
quantity: 3,
},
})
expect(wrapper.text()).toContain('3')
})
})
describe('quantity stepper', () => {
it('enables add button when canAdd is true', () => {
/**
* Test add button enabled state.
*
* When users can add more copies (haven't hit 4-copy limit),
* the add button should be enabled.
*/
const card = createMockCard()
const wrapper = mount(DeckCardRow, {
props: {
card,
quantity: 1,
canAdd: true,
},
})
const buttons = wrapper.findAll('button')
const addButton = buttons.find(b => b.attributes('aria-label')?.includes('Add'))
expect(addButton?.attributes('disabled')).toBeUndefined()
})
it('disables add button when canAdd is false', () => {
/**
* Test add button disabled at limit.
*
* When the 4-copy limit is reached, the add button should
* be disabled to prevent violations.
*/
const card = createMockCard()
const wrapper = mount(DeckCardRow, {
props: {
card,
quantity: 4,
canAdd: false,
},
})
const buttons = wrapper.findAll('button')
const addButton = buttons.find(b => b.attributes('aria-label')?.includes('Add'))
expect(addButton?.attributes('disabled')).toBeDefined()
})
it('enables remove button when quantity > 0', () => {
/**
* Test remove button enabled with cards.
*
* When there are cards in the deck, users should be able
* to remove them.
*/
const card = createMockCard()
const wrapper = mount(DeckCardRow, {
props: {
card,
quantity: 1,
},
})
const buttons = wrapper.findAll('button')
const removeButton = buttons.find(b => b.attributes('aria-label')?.includes('Remove'))
expect(removeButton?.attributes('disabled')).toBeUndefined()
})
it('disables remove button when quantity is 0', () => {
/**
* Test remove button disabled at zero.
*
* When there are no cards, the remove button should be
* disabled.
*/
const card = createMockCard()
const wrapper = mount(DeckCardRow, {
props: {
card,
quantity: 0,
},
})
const buttons = wrapper.findAll('button')
const removeButton = buttons.find(b => b.attributes('aria-label')?.includes('Remove'))
expect(removeButton?.attributes('disabled')).toBeDefined()
})
it('emits add event when add button clicked', async () => {
/**
* Test add button functionality.
*
* Clicking the add button should emit an event with the
* card ID so the parent can add a copy.
*/
const card = createMockCard({ id: 'pikachu-001' })
const wrapper = mount(DeckCardRow, {
props: {
card,
quantity: 1,
canAdd: true,
},
})
const buttons = wrapper.findAll('button')
const addButton = buttons.find(b => b.attributes('aria-label')?.includes('Add'))
await addButton?.trigger('click')
expect(wrapper.emitted('add')).toBeTruthy()
expect(wrapper.emitted('add')?.[0]).toEqual(['pikachu-001'])
})
it('emits remove event when remove button clicked', async () => {
/**
* Test remove button functionality.
*
* Clicking the remove button should emit an event with the
* card ID so the parent can remove a copy.
*/
const card = createMockCard({ id: 'pikachu-001' })
const wrapper = mount(DeckCardRow, {
props: {
card,
quantity: 2,
},
})
const buttons = wrapper.findAll('button')
const removeButton = buttons.find(b => b.attributes('aria-label')?.includes('Remove'))
await removeButton?.trigger('click')
expect(wrapper.emitted('remove')).toBeTruthy()
expect(wrapper.emitted('remove')?.[0]).toEqual(['pikachu-001'])
})
})
describe('card click handling', () => {
it('emits click event when row is clicked', async () => {
/**
* Test row click for card details.
*
* Clicking the row should open card details view.
*/
const card = createMockCard({ id: 'pikachu-001' })
const wrapper = mount(DeckCardRow, {
props: {
card,
quantity: 1,
},
})
await wrapper.trigger('click')
expect(wrapper.emitted('click')).toBeTruthy()
expect(wrapper.emitted('click')?.[0]).toEqual(['pikachu-001'])
})
it('handles Enter key to click', async () => {
/**
* Test keyboard activation with Enter.
*
* Pressing Enter should trigger the same action as clicking
* for keyboard accessibility.
*/
const card = createMockCard({ id: 'pikachu-001' })
const wrapper = mount(DeckCardRow, {
props: {
card,
quantity: 1,
},
})
await wrapper.trigger('keydown', { key: 'Enter' })
expect(wrapper.emitted('click')).toBeTruthy()
expect(wrapper.emitted('click')?.[0]).toEqual(['pikachu-001'])
})
it('handles Space key to click', async () => {
/**
* Test keyboard activation with Space.
*
* Pressing Space should trigger the same action as clicking
* for keyboard accessibility.
*/
const card = createMockCard({ id: 'pikachu-001' })
const wrapper = mount(DeckCardRow, {
props: {
card,
quantity: 1,
},
})
await wrapper.trigger('keydown', { key: ' ' })
expect(wrapper.emitted('click')).toBeTruthy()
})
it('does not emit click when disabled', async () => {
/**
* Test disabled state blocks clicks.
*
* When disabled (e.g., during save), clicking should not
* emit events.
*/
const card = createMockCard()
const wrapper = mount(DeckCardRow, {
props: {
card,
quantity: 1,
disabled: true,
},
})
await wrapper.trigger('click')
expect(wrapper.emitted('click')).toBeFalsy()
})
})
describe('disabled state', () => {
it('applies opacity when disabled', () => {
/**
* Test visual disabled state.
*
* Disabled rows should appear faded to indicate they're
* not interactive.
*/
const card = createMockCard()
const wrapper = mount(DeckCardRow, {
props: {
card,
quantity: 1,
disabled: true,
},
})
const row = wrapper.find('[role="button"]')
expect(row.classes()).toContain('opacity-50')
})
it('disables add button when row is disabled', () => {
/**
* Test buttons disabled when row disabled.
*
* All interactive elements should be disabled during save.
*/
const card = createMockCard()
const wrapper = mount(DeckCardRow, {
props: {
card,
quantity: 1,
canAdd: true,
disabled: true,
},
})
const buttons = wrapper.findAll('button')
const addButton = buttons.find(b => b.attributes('aria-label')?.includes('Add'))
expect(addButton?.attributes('disabled')).toBeDefined()
})
it('disables remove button when row is disabled', () => {
/**
* Test remove button disabled state.
*
* Remove button should also be disabled during save.
*/
const card = createMockCard()
const wrapper = mount(DeckCardRow, {
props: {
card,
quantity: 1,
disabled: true,
},
})
const buttons = wrapper.findAll('button')
const removeButton = buttons.find(b => b.attributes('aria-label')?.includes('Remove'))
expect(removeButton?.attributes('disabled')).toBeDefined()
})
it('does not emit add when disabled', async () => {
/**
* Test add blocked when disabled.
*
* Even if the button is somehow clicked, the event handler
* should check disabled state.
*/
const card = createMockCard()
const wrapper = mount(DeckCardRow, {
props: {
card,
quantity: 1,
canAdd: true,
disabled: true,
},
})
const buttons = wrapper.findAll('button')
const addButton = buttons.find(b => b.attributes('aria-label')?.includes('Add'))
await addButton?.trigger('click')
expect(wrapper.emitted('add')).toBeFalsy()
})
it('does not emit remove when disabled', async () => {
/**
* Test remove blocked when disabled.
*
* Remove should also be blocked when disabled.
*/
const card = createMockCard()
const wrapper = mount(DeckCardRow, {
props: {
card,
quantity: 2,
disabled: true,
},
})
const buttons = wrapper.findAll('button')
const removeButton = buttons.find(b => b.attributes('aria-label')?.includes('Remove'))
await removeButton?.trigger('click')
expect(wrapper.emitted('remove')).toBeFalsy()
})
})
describe('drag and drop', () => {
it('applies drag handlers when provided', () => {
/**
* Test drag handlers binding.
*
* When drag handlers are provided, they should be bound to
* the row element.
*/
const card = createMockCard()
const mockDragHandlers = {
draggable: true,
onDragstart: () => {},
onDragend: () => {},
}
const wrapper = mount(DeckCardRow, {
props: {
card,
quantity: 1,
dragHandlers: mockDragHandlers,
},
})
const row = wrapper.find('[role="button"]')
expect(row.attributes('draggable')).toBe('true')
})
it('shows grab cursor when draggable and not disabled', () => {
/**
* Test drag cursor style.
*
* Draggable rows should show a grab cursor to indicate they
* can be dragged.
*/
const card = createMockCard()
const wrapper = mount(DeckCardRow, {
props: {
card,
quantity: 1,
dragHandlers: { draggable: true },
},
})
const row = wrapper.find('[role="button"]')
expect(row.classes()).toContain('cursor-grab')
})
it('shows grabbing cursor when dragging', () => {
/**
* Test active drag cursor.
*
* While being dragged, the cursor should change to grabbing.
*/
const card = createMockCard()
const wrapper = mount(DeckCardRow, {
props: {
card,
quantity: 1,
dragHandlers: { draggable: true },
isDragging: true,
},
})
const row = wrapper.find('[role="button"]')
expect(row.classes()).toContain('cursor-grabbing')
})
it('applies opacity when dragging', () => {
/**
* Test drag visual feedback.
*
* The row being dragged should be semi-transparent.
*/
const card = createMockCard()
const wrapper = mount(DeckCardRow, {
props: {
card,
quantity: 1,
isDragging: true,
},
})
const row = wrapper.find('[role="button"]')
expect(row.classes()).toContain('opacity-50')
})
it('shows pointer cursor when not draggable', () => {
/**
* Test non-draggable cursor.
*
* Rows without drag handlers should show a pointer cursor
* for clicking.
*/
const card = createMockCard()
const wrapper = mount(DeckCardRow, {
props: {
card,
quantity: 1,
dragHandlers: undefined,
},
})
const row = wrapper.find('[role="button"]')
expect(row.classes()).toContain('cursor-pointer')
})
})
describe('edge cases', () => {
it('handles quantity of 0', () => {
/**
* Test zero quantity display.
*
* Even with 0 quantity, the row should render correctly
* (used in collection view).
*/
const card = createMockCard()
const wrapper = mount(DeckCardRow, {
props: {
card,
quantity: 0,
},
})
expect(wrapper.text()).toContain('0')
})
it('handles high quantities', () => {
/**
* Test high quantity display.
*
* The component should handle quantities beyond the normal
* 4-copy limit (for testing or special cards).
*/
const card = createMockCard()
const wrapper = mount(DeckCardRow, {
props: {
card,
quantity: 99,
},
})
expect(wrapper.text()).toContain('99')
})
it('stops event propagation from stepper buttons', async () => {
/**
* Test click event isolation.
*
* Clicking add/remove buttons should not trigger the row
* click event (which opens card details).
*/
const card = createMockCard()
const wrapper = mount(DeckCardRow, {
props: {
card,
quantity: 1,
canAdd: true,
},
})
// The stepper container has @click.stop
const stepperContainer = wrapper.find('.flex.items-center.rounded-lg.border')
await stepperContainer.trigger('click')
// Row click should not have been emitted
expect(wrapper.emitted('click')).toBeFalsy()
})
it('handles long card names with truncation', () => {
/**
* Test long name display.
*
* Very long card names should be truncated with ellipsis
* to prevent layout issues.
*/
const card = createMockCard({ name: 'A'.repeat(100) })
const wrapper = mount(DeckCardRow, {
props: {
card,
quantity: 1,
},
})
const nameSpan = wrapper.find('.text-sm.font-medium')
expect(nameSpan.classes()).toContain('truncate')
})
it('handles cards with no type (colorless border)', () => {
/**
* Test default border for cards without type.
*
* Cards without a type should get a default border color.
*/
const card = createMockCard({ type: undefined })
const wrapper = mount(DeckCardRow, {
props: {
card,
quantity: 1,
},
})
const thumbnail = wrapper.find('.w-10.h-14')
expect(thumbnail.classes()).toContain('border-surface-light')
})
it('applies correct border color for each type', () => {
/**
* Test type-based border colors.
*
* Each Pokemon type should have its own border color.
*/
const types = ['fire', 'water', 'lightning', 'grass']
types.forEach(type => {
const card = createMockCard({ type: type as EnergyType })
const wrapper = mount(DeckCardRow, {
props: {
card,
quantity: 1,
},
})
const thumbnail = wrapper.find('.w-10.h-14')
expect(thumbnail.classes()).toContain(`border-type-${type}`)
})
})
})
describe('accessibility', () => {
it('has role="button"', () => {
/**
* Test ARIA role.
*
* The row should have role="button" since it's clickable.
*/
const card = createMockCard()
const wrapper = mount(DeckCardRow, {
props: {
card,
quantity: 1,
},
})
const row = wrapper.find('[role="button"]')
expect(row.exists()).toBe(true)
})
it('has tabindex for keyboard navigation', () => {
/**
* Test keyboard focusability.
*
* The row should be focusable via Tab key.
*/
const card = createMockCard()
const wrapper = mount(DeckCardRow, {
props: {
card,
quantity: 1,
},
})
const row = wrapper.find('[role="button"]')
expect(row.attributes('tabindex')).toBe('0')
})
it('has descriptive aria-label', () => {
/**
* Test screen reader support.
*
* The row should have a label that describes the card and
* quantity for screen reader users.
*/
const card = createMockCard({ name: 'Pikachu' })
const wrapper = mount(DeckCardRow, {
props: {
card,
quantity: 3,
},
})
const row = wrapper.find('[role="button"]')
const label = row.attributes('aria-label')
expect(label).toContain('Pikachu')
expect(label).toContain('3')
})
it('add button has descriptive aria-label', () => {
/**
* Test button accessibility.
*
* Buttons should have clear labels for screen readers.
*/
const card = createMockCard()
const wrapper = mount(DeckCardRow, {
props: {
card,
quantity: 1,
},
})
const buttons = wrapper.findAll('button')
const addButton = buttons.find(b => b.attributes('aria-label')?.includes('Add'))
expect(addButton?.attributes('aria-label')).toBe('Add one copy')
})
it('remove button has descriptive aria-label', () => {
/**
* Test button accessibility.
*
* Buttons should have clear labels for screen readers.
*/
const card = createMockCard()
const wrapper = mount(DeckCardRow, {
props: {
card,
quantity: 1,
},
})
const buttons = wrapper.findAll('button')
const removeButton = buttons.find(b => b.attributes('aria-label')?.includes('Remove'))
expect(removeButton?.attributes('aria-label')).toBe('Remove one copy')
})
it('SVG icons have aria-hidden', () => {
/**
* Test icon accessibility.
*
* Decorative icons should be hidden from screen readers.
*/
const card = createMockCard()
const wrapper = mount(DeckCardRow, {
props: {
card,
quantity: 1,
},
})
const svgs = wrapper.findAll('svg')
svgs.forEach(svg => {
expect(svg.attributes('aria-hidden')).toBe('true')
})
})
})
})

View File

@ -0,0 +1,398 @@
/**
* Tests for DeckHeader component.
*
* These tests verify the deck name input works correctly with
* proper validation and error states.
*/
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import DeckHeader from './DeckHeader.vue'
describe('DeckHeader', () => {
describe('rendering', () => {
it('renders deck name input', () => {
/**
* Test that the input element is rendered.
*
* Users need an input field to name their deck.
*/
const wrapper = mount(DeckHeader, {
props: {
deckName: '',
isValidating: false,
},
})
const input = wrapper.find('input[type="text"]')
expect(input.exists()).toBe(true)
})
it('displays current deck name', () => {
/**
* Test that the input shows the current deck name.
*
* When editing an existing deck, the name should be
* pre-filled in the input.
*/
const wrapper = mount(DeckHeader, {
props: {
deckName: 'Fire Deck',
isValidating: false,
},
})
const input = wrapper.find('input[type="text"]')
expect((input.element as HTMLInputElement).value).toBe('Fire Deck')
})
it('shows placeholder when deck name is empty', () => {
/**
* Test placeholder text.
*
* Empty inputs should show helpful placeholder text.
*/
const wrapper = mount(DeckHeader, {
props: {
deckName: '',
isValidating: false,
},
})
const input = wrapper.find('input[type="text"]')
expect(input.attributes('placeholder')).toBe('Deck Name')
})
})
describe('input handling', () => {
it('emits update:deckName when user types', async () => {
/**
* Test input event emission.
*
* When the user types in the input, the parent component
* should be notified via v-model binding.
*/
const wrapper = mount(DeckHeader, {
props: {
deckName: '',
isValidating: false,
},
})
const input = wrapper.find('input[type="text"]')
await input.setValue('New Deck Name')
await input.trigger('input')
expect(wrapper.emitted('update:deckName')).toBeTruthy()
expect(wrapper.emitted('update:deckName')?.[0]).toEqual(['New Deck Name'])
})
it('emits update for each character typed', async () => {
/**
* Test real-time updates.
*
* The component should emit for each keystroke so the parent
* can validate and show save button states in real-time.
*/
const wrapper = mount(DeckHeader, {
props: {
deckName: '',
isValidating: false,
},
})
const input = wrapper.find('input[type="text"]')
// Type "F" (setValue automatically triggers input event)
await input.setValue('F')
expect(wrapper.emitted('update:deckName')?.length).toBe(1)
expect(wrapper.emitted('update:deckName')?.[0]).toEqual(['F'])
// Type "i" (setValue automatically triggers input event)
await input.setValue('Fi')
expect(wrapper.emitted('update:deckName')?.length).toBe(2)
expect(wrapper.emitted('update:deckName')?.[1]).toEqual(['Fi'])
})
it('handles empty string input', async () => {
/**
* Test clearing the input.
*
* Users should be able to clear the deck name by deleting
* all text.
*/
const wrapper = mount(DeckHeader, {
props: {
deckName: 'Old Name',
isValidating: false,
},
})
const input = wrapper.find('input[type="text"]')
await input.setValue('')
await input.trigger('input')
expect(wrapper.emitted('update:deckName')?.[0]).toEqual([''])
})
it('handles whitespace input', async () => {
/**
* Test whitespace-only input.
*
* The component should emit whitespace as-is - validation
* happens in the parent component.
*/
const wrapper = mount(DeckHeader, {
props: {
deckName: '',
isValidating: false,
},
})
const input = wrapper.find('input[type="text"]')
await input.setValue(' ')
await input.trigger('input')
expect(wrapper.emitted('update:deckName')?.[0]).toEqual([' '])
})
it('handles special characters and emojis', async () => {
/**
* Test special character support.
*
* Users should be able to use unicode characters, emojis,
* and special characters in deck names.
*/
const wrapper = mount(DeckHeader, {
props: {
deckName: '',
isValidating: false,
},
})
const input = wrapper.find('input[type="text"]')
const specialName = '🔥 Fire Deck! #1 (2026)'
await input.setValue(specialName)
await input.trigger('input')
expect(wrapper.emitted('update:deckName')?.[0]).toEqual([specialName])
})
it('handles very long names', async () => {
/**
* Test long input handling.
*
* Very long names should be accepted (length validation
* happens server-side).
*/
const wrapper = mount(DeckHeader, {
props: {
deckName: '',
isValidating: false,
},
})
const input = wrapper.find('input[type="text"]')
const longName = 'A'.repeat(200)
await input.setValue(longName)
await input.trigger('input')
expect(wrapper.emitted('update:deckName')?.[0]).toEqual([longName])
})
})
describe('validation states', () => {
it('does not show validation indicator when not validating', () => {
/**
* Test normal state has no validation UI.
*
* When not validating, the input should appear normal
* without any loading indicators.
*/
const wrapper = mount(DeckHeader, {
props: {
deckName: 'Test Deck',
isValidating: false,
},
})
// Should not contain any validation-related text
expect(wrapper.text()).not.toContain('Validating')
expect(wrapper.text()).not.toContain('...')
})
it('accepts isValidating prop for future validation state display', () => {
/**
* Test isValidating prop is accepted.
*
* The component accepts an isValidating prop for potential
* future validation state display.
*/
const wrapper = mount(DeckHeader, {
props: {
deckName: 'Test Deck',
isValidating: true,
},
})
// Component should mount without errors with isValidating=true
expect(wrapper.exists()).toBe(true)
})
})
describe('edge cases', () => {
it('handles rapid typing without issues', async () => {
/**
* Test rapid input changes.
*
* Rapid typing should not cause issues or dropped characters.
*/
const wrapper = mount(DeckHeader, {
props: {
deckName: '',
isValidating: false,
},
})
const input = wrapper.find('input[type="text"]')
// Simulate rapid typing (setValue automatically triggers input event)
for (let i = 0; i < 10; i++) {
await input.setValue(`Name ${i}`)
}
expect(wrapper.emitted('update:deckName')?.length).toBe(10)
})
it('handles prop updates correctly', async () => {
/**
* Test external prop changes.
*
* When the parent updates the deckName prop (e.g., after
* loading a deck), the input should reflect the new value.
*/
const wrapper = mount(DeckHeader, {
props: {
deckName: 'Initial Name',
isValidating: false,
},
})
// Parent updates the deck name
await wrapper.setProps({ deckName: 'Updated Name' })
const input = wrapper.find('input[type="text"]')
expect((input.element as HTMLInputElement).value).toBe('Updated Name')
})
it('handles switching between empty and non-empty', async () => {
/**
* Test toggling between empty and populated states.
*
* Switching back and forth between empty and filled should
* work correctly.
*/
const wrapper = mount(DeckHeader, {
props: {
deckName: 'Name',
isValidating: false,
},
})
const input = wrapper.find('input[type="text"]')
// Clear it
await wrapper.setProps({ deckName: '' })
expect((input.element as HTMLInputElement).value).toBe('')
// Add name back
await wrapper.setProps({ deckName: 'New Name' })
expect((input.element as HTMLInputElement).value).toBe('New Name')
})
})
describe('accessibility', () => {
it('input has proper type attribute', () => {
/**
* Test input type.
*
* The input should be type="text" for proper mobile keyboard
* and accessibility support.
*/
const wrapper = mount(DeckHeader, {
props: {
deckName: '',
isValidating: false,
},
})
const input = wrapper.find('input')
expect(input.attributes('type')).toBe('text')
})
it('input is keyboard accessible', () => {
/**
* Test keyboard accessibility.
*
* The input should be focusable and usable via keyboard.
*/
const wrapper = mount(DeckHeader, {
props: {
deckName: '',
isValidating: false,
},
})
const input = wrapper.find('input')
// Input should not have tabindex=-1 or disabled
expect(input.attributes('tabindex')).not.toBe('-1')
expect(input.attributes('disabled')).toBeUndefined()
})
it('placeholder provides context for screen readers', () => {
/**
* Test placeholder accessibility.
*
* The placeholder text should be descriptive enough for
* screen reader users to understand the input's purpose.
*/
const wrapper = mount(DeckHeader, {
props: {
deckName: '',
isValidating: false,
},
})
const input = wrapper.find('input')
const placeholder = input.attributes('placeholder')
expect(placeholder).toBeTruthy()
expect(placeholder).toContain('Deck')
})
})
describe('styling', () => {
it('has proper input styling classes', () => {
/**
* Test input has expected styles.
*
* The input should have appropriate styling classes for
* the deck builder design.
*/
const wrapper = mount(DeckHeader, {
props: {
deckName: '',
isValidating: false,
},
})
const input = wrapper.find('input')
const classes = input.classes().join(' ')
// Should have full width
expect(classes).toContain('w-full')
// Should have text styling
expect(classes).toContain('text-xl')
expect(classes).toContain('font-bold')
})
})
})

View File

@ -522,4 +522,502 @@ describe('useDragDrop', () => {
expect(dragSource.value).toBeNull()
})
})
describe('edge cases', () => {
describe('drop outside drop zone', () => {
it('should not call callback when dropped outside any drop zone', () => {
/**
* Test that dropping outside a drop zone doesn't trigger callbacks.
*
* When a card is dragged but dropped in empty space (not over any
* drop target), no action should be taken. This prevents accidental
* card removal or addition.
*/
const { createDragHandlers, createDropHandlers, isDragging } = useDragDrop()
const card = createMockCard()
const onDropCallback = vi.fn()
// Start a drag
const dragHandlers = createDragHandlers(card, 'collection')
dragHandlers.onDragstart(createMockDragEvent('dragstart'))
expect(isDragging.value).toBe(true)
// Register a drop target but don't trigger its drop handler
createDropHandlers('deck', () => true, onDropCallback)
// End drag without dropping on target (simulating drop in empty space)
dragHandlers.onDragend(createMockDragEvent('dragend'))
// Callback should not have been called
expect(onDropCallback).not.toHaveBeenCalled()
expect(isDragging.value).toBe(false)
})
})
describe('data transfer fallback', () => {
it('should recover card data from dataTransfer when draggedCard is null', () => {
/**
* Test data recovery from dataTransfer on drop.
*
* In some browser scenarios (cross-frame drag, external drag source),
* draggedCard might be null but we can still recover the card data
* from the event's dataTransfer object.
*/
const { createDropHandlers } = useDragDrop()
const card = createMockCard()
const onDropCallback = vi.fn()
const dropHandlers = createDropHandlers('deck', () => true, onDropCallback)
// Create drop event with card data in dataTransfer but no draggedCard state
const cardJson = JSON.stringify(card)
const dropEvent = createMockDragEvent('drop', {
getData: vi.fn((type: string) => {
if (type === 'application/x-mantimon-card') return cardJson
return ''
}),
})
dropHandlers.onDrop(dropEvent)
expect(onDropCallback).toHaveBeenCalledWith(card)
})
it('should handle invalid JSON in dataTransfer gracefully', () => {
/**
* Test that invalid JSON in dataTransfer doesn't crash.
*
* If the dataTransfer contains malformed JSON (corrupted data,
* external drag source), the drop should fail gracefully without
* throwing an error.
*/
const { createDropHandlers } = useDragDrop()
const onDropCallback = vi.fn()
const dropHandlers = createDropHandlers('deck', () => true, onDropCallback)
// Create drop event with invalid JSON
const dropEvent = createMockDragEvent('drop', {
getData: vi.fn(() => '{invalid json'),
})
// Should not throw
expect(() => dropHandlers.onDrop(dropEvent)).not.toThrow()
// Callback should not have been called
expect(onDropCallback).not.toHaveBeenCalled()
})
it('should handle empty dataTransfer gracefully', () => {
/**
* Test that empty dataTransfer is handled without errors.
*
* If neither draggedCard state nor dataTransfer have card data,
* the drop should be silently ignored.
*/
const { createDropHandlers } = useDragDrop()
const onDropCallback = vi.fn()
const dropHandlers = createDropHandlers('deck', () => true, onDropCallback)
// Create drop event with no data
const dropEvent = createMockDragEvent('drop', {
getData: vi.fn(() => ''),
})
// Should not throw
expect(() => dropHandlers.onDrop(dropEvent)).not.toThrow()
// Callback should not have been called
expect(onDropCallback).not.toHaveBeenCalled()
})
})
describe('dragleave edge cases', () => {
it('should not clear dropTarget when entering a child element', () => {
/**
* Test that dragleave doesn't clear state when entering children.
*
* When a drag moves from a parent drop zone to a child element
* within it, dragleave fires on the parent. We should only clear
* the drop state if actually leaving the drop zone entirely.
*/
const { createDragHandlers, createDropHandlers, dropTarget } = useDragDrop()
const card = createMockCard()
// Start a drag and hover over deck
const dragHandlers = createDragHandlers(card, 'collection')
dragHandlers.onDragstart(createMockDragEvent('dragstart'))
const dropHandlers = createDropHandlers('deck', () => true, vi.fn())
dropHandlers.onDragover(createMockDragEvent('dragover'))
expect(dropTarget.value).toBe('deck')
// Create a dragleave event where related target is a child
const parentDiv = document.createElement('div')
const childDiv = document.createElement('div')
parentDiv.appendChild(childDiv)
const leaveEvent = createMockDragEvent('dragleave')
Object.defineProperty(leaveEvent, 'relatedTarget', { value: childDiv })
Object.defineProperty(leaveEvent, 'currentTarget', { value: parentDiv })
dropHandlers.onDragleave(leaveEvent)
// Should NOT clear because we entered a child
expect(dropTarget.value).toBe('deck')
})
})
describe('dragover without active drag', () => {
it('should handle dragover when no drag is active', () => {
/**
* Test that dragover without an active drag is handled gracefully.
*
* This can happen in edge cases like external drag sources or
* race conditions. The handler should not crash or update state.
*/
const { createDropHandlers, dropTarget, isValidDrop } = useDragDrop()
const dropHandlers = createDropHandlers('deck', () => true, vi.fn())
// Trigger dragover without starting a drag first
const event = createMockDragEvent('dragover')
// Should not throw
expect(() => dropHandlers.onDragover(event)).not.toThrow()
// State should remain unchanged
expect(dropTarget.value).toBeNull()
expect(isValidDrop.value).toBe(false)
})
})
describe('touch edge cases', () => {
it('should handle touchstart with no touches', () => {
/**
* Test that touchstart with empty touches array doesn't crash.
*
* This can occur in certain edge cases or on some devices.
* The handler should return early without errors.
*/
const { createDragHandlers, isDragging } = useDragDrop()
const card = createMockCard()
const handlers = createDragHandlers(card, 'collection')
// Touch event with no touches
const touchEvent = createMockTouchEvent('touchstart', [])
// Should not throw
expect(() => handlers.onTouchstart(touchEvent)).not.toThrow()
expect(isDragging.value).toBe(false)
})
it('should handle touchmove with no touches', () => {
/**
* Test that touchmove with empty touches array doesn't crash.
*
* Touch arrays can be empty during certain event sequences.
* The handler should return early without errors.
*/
const { createDragHandlers } = useDragDrop()
const card = createMockCard()
const handlers = createDragHandlers(card, 'collection')
// Start a touch first
handlers.onTouchstart(createMockTouchEvent('touchstart', [{ clientX: 100, clientY: 100 }]))
// Touchmove with no touches
const touchEvent = createMockTouchEvent('touchmove', [])
// Should not throw
expect(() => handlers.onTouchmove(touchEvent)).not.toThrow()
})
it('should clear long press timer if touchend occurs before timeout', () => {
/**
* Test that releasing touch before long-press completes cancels the drag.
*
* If the user taps quickly (less than 500ms), the drag should not
* start. This ensures long-press is required for drag operations.
*/
const { createDragHandlers, isDragging } = useDragDrop()
const card = createMockCard()
const handlers = createDragHandlers(card, 'collection')
// Start touch
handlers.onTouchstart(createMockTouchEvent('touchstart', [{ clientX: 100, clientY: 100 }]))
// End touch before timeout (at 200ms)
vi.advanceTimersByTime(200)
handlers.onTouchend(createMockTouchEvent('touchend'))
// Advance past when drag would have started
vi.advanceTimersByTime(400)
// Should NOT be dragging
expect(isDragging.value).toBe(false)
})
it('should allow small finger movement during long press', () => {
/**
* Test that tiny finger movements don't cancel long-press.
*
* Users' fingers naturally shift slightly while holding. Movement
* under 10 pixels should not cancel the long-press detection.
*/
const { createDragHandlers, isDragging } = useDragDrop()
const card = createMockCard()
const handlers = createDragHandlers(card, 'collection')
// Start touch
handlers.onTouchstart(createMockTouchEvent('touchstart', [{ clientX: 100, clientY: 100 }]))
// Small movement (within threshold)
handlers.onTouchmove(createMockTouchEvent('touchmove', [{ clientX: 105, clientY: 103 }]))
// Wait for long press duration
vi.advanceTimersByTime(500)
// Should still trigger drag because movement was within threshold
expect(isDragging.value).toBe(true)
})
it('should call onDrop callback when touch released over valid drop zone', () => {
/**
* Test that touch drag and drop triggers the callback.
*
* When a user drags via long-press and releases over a valid drop
* zone, the card should be added/removed as expected.
*/
const { createDragHandlers, createDropHandlers } = useDragDrop()
const card = createMockCard()
const onDropCallback = vi.fn()
// Register drop target
createDropHandlers('deck', () => true, onDropCallback)
// Start long-press drag
const handlers = createDragHandlers(card, 'collection')
handlers.onTouchstart(createMockTouchEvent('touchstart', [{ clientX: 100, clientY: 100 }]))
vi.advanceTimersByTime(500)
// Mock document.elementFromPoint to simulate being over drop zone
const mockDropZone = document.createElement('div')
mockDropZone.dataset.dropTarget = 'deck'
const elementFromPointMock = vi.fn().mockReturnValue(mockDropZone)
document.elementFromPoint = elementFromPointMock
// Move over drop zone
handlers.onTouchmove(createMockTouchEvent('touchmove', [{ clientX: 200, clientY: 200 }]))
// Release
handlers.onTouchend(createMockTouchEvent('touchend'))
expect(onDropCallback).toHaveBeenCalledWith(card)
})
it('should not call onDrop callback when touch released outside drop zone', () => {
/**
* Test that touch release outside drop zones doesn't trigger callback.
*
* If the user drags via touch but releases in empty space, no
* drop should occur.
*/
const { createDragHandlers, createDropHandlers } = useDragDrop()
const card = createMockCard()
const onDropCallback = vi.fn()
// Register drop target
createDropHandlers('deck', () => true, onDropCallback)
// Start long-press drag
const handlers = createDragHandlers(card, 'collection')
handlers.onTouchstart(createMockTouchEvent('touchstart', [{ clientX: 100, clientY: 100 }]))
vi.advanceTimersByTime(500)
// Mock document.elementFromPoint to simulate being over empty space
const elementFromPointMock = vi.fn().mockReturnValue(document.createElement('div'))
document.elementFromPoint = elementFromPointMock
// Move and release (not over a drop zone)
handlers.onTouchmove(createMockTouchEvent('touchmove', [{ clientX: 200, clientY: 200 }]))
handlers.onTouchend(createMockTouchEvent('touchend'))
expect(onDropCallback).not.toHaveBeenCalled()
})
it('should not call onDrop callback when touch released over invalid drop zone', () => {
/**
* Test that touch release over invalid drop zones doesn't trigger callback.
*
* If the drop zone's canDrop returns false (e.g., deck is full),
* the touch release should not add the card.
*/
const { createDragHandlers, createDropHandlers } = useDragDrop()
const card = createMockCard()
const onDropCallback = vi.fn()
// Register drop target that rejects the card
createDropHandlers('deck', () => false, onDropCallback)
// Start long-press drag
const handlers = createDragHandlers(card, 'collection')
handlers.onTouchstart(createMockTouchEvent('touchstart', [{ clientX: 100, clientY: 100 }]))
vi.advanceTimersByTime(500)
// Mock document.elementFromPoint to simulate being over drop zone
const mockDropZone = document.createElement('div')
mockDropZone.dataset.dropTarget = 'deck'
const elementFromPointMock = vi.fn().mockReturnValue(mockDropZone)
document.elementFromPoint = elementFromPointMock
// Move over drop zone (will be marked invalid)
handlers.onTouchmove(createMockTouchEvent('touchmove', [{ clientX: 200, clientY: 200 }]))
// Release
handlers.onTouchend(createMockTouchEvent('touchend'))
// Should not call callback because drop was invalid
expect(onDropCallback).not.toHaveBeenCalled()
})
it('should prevent scrolling during active touch drag', () => {
/**
* Test that touchmove prevents default during active drag.
*
* When dragging a card via touch, the page should not scroll.
* This is essential for a good mobile drag experience.
*/
const { createDragHandlers } = useDragDrop()
const card = createMockCard()
const handlers = createDragHandlers(card, 'collection')
// Mock document.elementFromPoint (needed for touchmove during drag)
const elementFromPointMock = vi.fn().mockReturnValue(document.createElement('div'))
document.elementFromPoint = elementFromPointMock
// Start long-press drag
handlers.onTouchstart(createMockTouchEvent('touchstart', [{ clientX: 100, clientY: 100 }]))
vi.advanceTimersByTime(500)
// Touchmove during drag
const moveEvent = createMockTouchEvent('touchmove', [{ clientX: 110, clientY: 110 }])
const preventDefaultSpy = vi.spyOn(moveEvent, 'preventDefault')
handlers.onTouchmove(moveEvent)
expect(preventDefaultSpy).toHaveBeenCalled()
})
it('should not prevent scrolling before long-press triggers', () => {
/**
* Test that touchmove allows default before drag starts.
*
* Before the long-press timeout completes, touchmove should not
* prevent default so users can still scroll normally.
*/
const { createDragHandlers } = useDragDrop()
const card = createMockCard()
const handlers = createDragHandlers(card, 'collection')
// Start touch but don't wait for long-press
handlers.onTouchstart(createMockTouchEvent('touchstart', [{ clientX: 100, clientY: 100 }]))
// Small movement (within threshold, so won't cancel)
const moveEvent = createMockTouchEvent('touchmove', [{ clientX: 102, clientY: 102 }])
const preventDefaultSpy = vi.spyOn(moveEvent, 'preventDefault')
handlers.onTouchmove(moveEvent)
// Should NOT prevent default because drag hasn't started yet
expect(preventDefaultSpy).not.toHaveBeenCalled()
})
it('should update drop target correctly during touch drag', () => {
/**
* Test that moving touch updates drop target state.
*
* As the user drags their finger across the screen, the current
* drop target should update to provide real-time visual feedback.
*/
const { createDragHandlers, createDropHandlers, dropTarget, isValidDrop } = useDragDrop()
const card = createMockCard()
// Register drop targets
createDropHandlers('deck', () => true, vi.fn())
createDropHandlers('collection', () => true, vi.fn())
// Start long-press drag
const handlers = createDragHandlers(card, 'deck')
handlers.onTouchstart(createMockTouchEvent('touchstart', [{ clientX: 100, clientY: 100 }]))
vi.advanceTimersByTime(500)
// Move over deck drop zone
const deckZone = document.createElement('div')
deckZone.dataset.dropTarget = 'deck'
const elementFromPointMock = vi.fn().mockReturnValue(deckZone)
document.elementFromPoint = elementFromPointMock
handlers.onTouchmove(createMockTouchEvent('touchmove', [{ clientX: 200, clientY: 200 }]))
expect(dropTarget.value).toBe('deck')
expect(isValidDrop.value).toBe(true)
// Move over collection drop zone
const collectionZone = document.createElement('div')
collectionZone.dataset.dropTarget = 'collection'
elementFromPointMock.mockReturnValue(collectionZone)
handlers.onTouchmove(createMockTouchEvent('touchmove', [{ clientX: 300, clientY: 300 }]))
expect(dropTarget.value).toBe('collection')
expect(isValidDrop.value).toBe(true)
})
})
describe('multiple drop targets', () => {
it('should handle multiple registered drop targets correctly', () => {
/**
* Test that multiple drop zones can coexist.
*
* The deck builder has two drop zones (deck and collection).
* Both should work independently without interfering with each other.
*/
const { createDragHandlers, createDropHandlers } = useDragDrop()
const card = createMockCard()
const deckCallback = vi.fn()
const collectionCallback = vi.fn()
// Register both drop targets
const deckHandlers = createDropHandlers('deck', () => true, deckCallback)
const collectionHandlers = createDropHandlers('collection', () => true, collectionCallback)
// Start drag
const dragHandlers = createDragHandlers(card, 'collection')
dragHandlers.onDragstart(createMockDragEvent('dragstart'))
// Drop on deck
deckHandlers.onDragover(createMockDragEvent('dragover'))
deckHandlers.onDrop(createMockDragEvent('drop'))
// Only deck callback should be called
expect(deckCallback).toHaveBeenCalledWith(card)
expect(collectionCallback).not.toHaveBeenCalled()
// Reset
deckCallback.mockClear()
collectionCallback.mockClear()
// Start new drag
dragHandlers.onDragstart(createMockDragEvent('dragstart'))
// Drop on collection
collectionHandlers.onDragover(createMockDragEvent('dragover'))
collectionHandlers.onDrop(createMockDragEvent('drop'))
// Only collection callback should be called
expect(collectionCallback).toHaveBeenCalledWith(card)
expect(deckCallback).not.toHaveBeenCalled()
})
})
})
})

View File

@ -0,0 +1,117 @@
/**
* Tests for CampaignPage.
*
* These tests verify the campaign page placeholder displays correctly
* (Phase F2 feature).
*/
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import CampaignPage from './CampaignPage.vue'
describe('CampaignPage', () => {
describe('rendering', () => {
it('renders page title', () => {
/**
* Test that the campaign title is displayed.
*
* Users should see a clear page heading.
*/
const wrapper = mount(CampaignPage)
expect(wrapper.text()).toContain('Campaign')
})
it('shows coming soon message', () => {
/**
* Test placeholder content.
*
* Users should know this feature is coming in a future phase.
*/
const wrapper = mount(CampaignPage)
expect(wrapper.text()).toContain('Campaign mode coming in Phase F2')
})
it('shows feature description', () => {
/**
* Test that users can see what campaign mode will offer.
*
* Even though it's not implemented yet, the description helps
* users understand what to expect.
*/
const wrapper = mount(CampaignPage)
expect(wrapper.text()).toContain('Challenge NPCs')
expect(wrapper.text()).toContain('defeat Club Leaders')
expect(wrapper.text()).toContain('become Champion')
})
})
describe('accessibility', () => {
it('has main heading as h1', () => {
/**
* Test heading hierarchy.
*
* The page should have a proper h1 for screen readers.
*/
const wrapper = mount(CampaignPage)
const h1 = wrapper.find('h1')
expect(h1.exists()).toBe(true)
expect(h1.text()).toContain('Campaign')
})
})
describe('layout', () => {
it('has container for proper spacing', () => {
/**
* Test layout structure.
*
* The page should have a container for consistent margins.
*/
const wrapper = mount(CampaignPage)
const container = wrapper.find('.container')
expect(container.exists()).toBe(true)
})
it('has centered content card', () => {
/**
* Test content presentation.
*
* The placeholder message should be in a styled card.
*/
const wrapper = mount(CampaignPage)
const card = wrapper.find('.bg-surface')
expect(card.exists()).toBe(true)
expect(card.classes()).toContain('rounded-lg')
})
})
describe('edge cases', () => {
it('mounts without errors', () => {
/**
* Test basic mounting.
*
* The component should mount successfully without throwing
* errors.
*/
expect(() => mount(CampaignPage)).not.toThrow()
})
it('renders even with no props or state', () => {
/**
* Test stateless rendering.
*
* Since this is a simple placeholder, it should render
* without any external data.
*/
const wrapper = mount(CampaignPage)
expect(wrapper.exists()).toBe(true)
expect(wrapper.text()).toBeTruthy()
})
})
})

View File

@ -0,0 +1,467 @@
/**
* Tests for HomePage.
*
* These tests verify the home page displays correctly for both
* authenticated and unauthenticated users with proper navigation.
*/
import { describe, it, expect, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import HomePage from './HomePage.vue'
import { useAuthStore } from '@/stores/auth'
// Stub RouterLink that preserves slot content
const RouterLinkStub = {
name: 'RouterLink',
template: '<a><slot /></a>',
props: ['to'],
}
describe('HomePage', () => {
beforeEach(() => {
// Create fresh Pinia instance for each test
const pinia = createPinia()
setActivePinia(pinia)
})
describe('rendering', () => {
it('renders page title', () => {
/**
* Test that the main title is displayed.
*
* The home page should clearly show the game name.
*/
const wrapper = mount(HomePage, {
global: {
stubs: { RouterLink: RouterLinkStub },
},
})
expect(wrapper.text()).toContain('Mantimon TCG')
})
it('renders tagline', () => {
/**
* Test that the description is displayed.
*
* The tagline helps users understand what the game is about.
*/
const wrapper = mount(HomePage, {
global: {
stubs: { RouterLink: RouterLinkStub },
},
})
expect(wrapper.text()).toContain('single-player trading card game')
})
it('renders feature cards', () => {
/**
* Test that feature highlights are displayed.
*
* The three feature cards (Campaign, Build Deck, PvP) help
* users understand what they can do in the game.
*/
const wrapper = mount(HomePage, {
global: {
stubs: { RouterLink: RouterLinkStub },
},
})
expect(wrapper.text()).toContain('Campaign Mode')
expect(wrapper.text()).toContain('Build Your Deck')
expect(wrapper.text()).toContain('PvP Battles')
})
})
describe('unauthenticated state', () => {
it('shows "Start Your Journey" button when not authenticated', () => {
/**
* Test unauthenticated user call-to-action.
*
* New users should see a button to start playing.
*/
// Get store and set state BEFORE mounting
const authStore = useAuthStore()
authStore.$patch({
accessToken: null,
refreshToken: null,
expiresAt: null,
user: null
})
const wrapper = mount(HomePage, {
global: {
stubs: { RouterLink: RouterLinkStub },
},
})
// Verify store state
expect(authStore.isAuthenticated).toBe(false)
expect(wrapper.text()).toContain('Start Your Journey')
})
it('shows "Continue Adventure" button when not authenticated', () => {
/**
* Test returning user login button.
*
* Users who already have an account should see a login option.
*/
const authStore = useAuthStore()
authStore.$patch({
accessToken: null,
refreshToken: null,
expiresAt: null,
user: null
})
const wrapper = mount(HomePage, {
global: {
stubs: { RouterLink: RouterLinkStub },
},
})
expect(authStore.isAuthenticated).toBe(false)
expect(wrapper.text()).toContain('Continue Adventure')
})
it('does not show authenticated navigation', () => {
/**
* Test that authenticated-only UI is hidden.
*
* Campaign and collection links should not appear for
* unauthenticated users.
*/
const authStore = useAuthStore()
authStore.$patch({
accessToken: null,
refreshToken: null,
expiresAt: null,
user: null
})
const wrapper = mount(HomePage, {
global: {
stubs: { RouterLink: RouterLinkStub },
},
})
expect(authStore.isAuthenticated).toBe(false)
expect(wrapper.text()).not.toContain('Continue Campaign')
expect(wrapper.text()).not.toContain('View Collection')
})
})
describe('authenticated state', () => {
it('shows "Continue Campaign" button when authenticated', () => {
/**
* Test authenticated user primary action.
*
* Logged-in users should see a direct link to campaign mode.
*/
const authStore = useAuthStore()
// Set accessToken to simulate authenticated state
authStore.$patch({
accessToken: 'mock-access-token',
refreshToken: 'mock-refresh-token',
expiresAt: Date.now() + 3600000
})
const wrapper = mount(HomePage, {
global: {
stubs: { RouterLink: RouterLinkStub },
},
})
expect(authStore.isAuthenticated).toBe(true)
expect(wrapper.text()).toContain('Continue Campaign')
})
it('shows "View Collection" button when authenticated', () => {
/**
* Test authenticated user secondary action.
*
* Users should be able to view their card collection.
*/
const authStore = useAuthStore()
authStore.$patch({
accessToken: 'mock-access-token',
refreshToken: 'mock-refresh-token',
expiresAt: Date.now() + 3600000
})
const wrapper = mount(HomePage, {
global: {
stubs: { RouterLink: RouterLinkStub },
},
})
expect(authStore.isAuthenticated).toBe(true)
expect(wrapper.text()).toContain('View Collection')
})
it('does not show unauthenticated navigation', () => {
/**
* Test that unauthenticated UI is hidden.
*
* Login/register buttons should not appear for authenticated
* users.
*/
const authStore = useAuthStore()
authStore.$patch({
accessToken: 'mock-access-token',
refreshToken: 'mock-refresh-token',
expiresAt: Date.now() + 3600000
})
const wrapper = mount(HomePage, {
global: {
stubs: { RouterLink: RouterLinkStub },
},
})
expect(authStore.isAuthenticated).toBe(true)
expect(wrapper.text()).not.toContain('Start Your Journey')
expect(wrapper.text()).not.toContain('Continue Adventure')
})
})
describe('navigation', () => {
it('renders campaign and collection links when authenticated', () => {
/**
* Test authenticated navigation links exist.
*
* When authenticated, users should have navigation links
* to campaign and collection pages.
*/
const authStore = useAuthStore()
authStore.$patch({
accessToken: 'mock-access-token',
refreshToken: 'mock-refresh-token',
expiresAt: Date.now() + 3600000
})
const wrapper = mount(HomePage, {
global: {
stubs: { RouterLink: RouterLinkStub },
},
})
expect(authStore.isAuthenticated).toBe(true)
// Check that RouterLink components exist with correct props
const routerLinks = wrapper.findAllComponents({ name: 'RouterLink' })
expect(routerLinks.length).toBeGreaterThanOrEqual(2)
})
it('renders login and register links when not authenticated', () => {
/**
* Test unauthenticated navigation links exist.
*
* When not authenticated, users should have navigation links
* to login and register pages.
*/
const authStore = useAuthStore()
authStore.$patch({
accessToken: null,
refreshToken: null,
expiresAt: null,
user: null
})
const wrapper = mount(HomePage, {
global: {
stubs: { RouterLink: RouterLinkStub },
},
})
expect(authStore.isAuthenticated).toBe(false)
// Check that RouterLink components exist
const routerLinks = wrapper.findAllComponents({ name: 'RouterLink' })
expect(routerLinks.length).toBeGreaterThanOrEqual(2)
})
})
describe('edge cases', () => {
it('handles auth state changes reactively', async () => {
/**
* Test reactive auth state updates.
*
* When auth state changes (login/logout), the UI should
* update immediately without a page reload.
*/
const authStore = useAuthStore()
authStore.$patch({
accessToken: null,
refreshToken: null,
expiresAt: null,
user: null
})
const wrapper = mount(HomePage, {
global: {
stubs: { RouterLink: RouterLinkStub },
},
})
// Initially unauthenticated
expect(authStore.isAuthenticated).toBe(false)
expect(wrapper.text()).toContain('Start Your Journey')
// Login
authStore.$patch({
accessToken: 'mock-access-token',
refreshToken: 'mock-refresh-token',
expiresAt: Date.now() + 3600000
})
await wrapper.vm.$nextTick()
// Should now show authenticated UI
expect(authStore.isAuthenticated).toBe(true)
expect(wrapper.text()).toContain('Continue Campaign')
expect(wrapper.text()).not.toContain('Start Your Journey')
})
it('handles logout gracefully', async () => {
/**
* Test logout state change.
*
* After logout, the UI should revert to unauthenticated state.
*/
const authStore = useAuthStore()
authStore.$patch({
accessToken: 'mock-access-token',
refreshToken: 'mock-refresh-token',
expiresAt: Date.now() + 3600000
})
const wrapper = mount(HomePage, {
global: {
stubs: { RouterLink: RouterLinkStub },
},
})
// Initially authenticated
expect(authStore.isAuthenticated).toBe(true)
expect(wrapper.text()).toContain('Continue Campaign')
// Logout
authStore.$patch({
accessToken: null,
refreshToken: null,
expiresAt: null
})
await wrapper.vm.$nextTick()
// Should now show unauthenticated UI
expect(authStore.isAuthenticated).toBe(false)
expect(wrapper.text()).toContain('Start Your Journey')
expect(wrapper.text()).not.toContain('Continue Campaign')
})
})
describe('accessibility', () => {
it('has main heading as h1', () => {
/**
* Test heading hierarchy.
*
* The page should have a proper h1 for screen readers.
*/
const wrapper = mount(HomePage, {
global: {
stubs: { RouterLink: RouterLinkStub },
},
})
const h1 = wrapper.find('h1')
expect(h1.exists()).toBe(true)
expect(h1.text()).toContain('Mantimon TCG')
})
it('feature cards have h3 headings', () => {
/**
* Test feature card accessibility.
*
* Each feature should have a proper heading for screen
* readers to navigate.
*/
const wrapper = mount(HomePage, {
global: {
stubs: { RouterLink: RouterLinkStub },
},
})
const h3s = wrapper.findAll('h3')
expect(h3s.length).toBe(3)
expect(h3s[0].text()).toContain('Campaign Mode')
expect(h3s[1].text()).toContain('Build Your Deck')
expect(h3s[2].text()).toContain('PvP Battles')
})
it('navigation buttons have clear labels', () => {
/**
* Test button text clarity.
*
* Buttons should have descriptive text that makes their
* purpose clear to all users.
*/
const authStore = useAuthStore()
authStore.$patch({
accessToken: null,
refreshToken: null,
expiresAt: null,
user: null
})
const wrapper = mount(HomePage, {
global: {
stubs: { RouterLink: RouterLinkStub },
},
})
expect(authStore.isAuthenticated).toBe(false)
// Button text should be clear and action-oriented
expect(wrapper.text()).toContain('Start Your Journey')
expect(wrapper.text()).toContain('Continue Adventure')
})
})
describe('responsive layout', () => {
it('has responsive grid classes for feature cards', () => {
/**
* Test responsive design.
*
* Feature cards should stack on mobile and show in a row
* on larger screens.
*/
const wrapper = mount(HomePage, {
global: {
stubs: { RouterLink: RouterLinkStub },
},
})
const grid = wrapper.find('.grid')
expect(grid.exists()).toBe(true)
expect(grid.classes()).toContain('grid-cols-1')
expect(grid.classes()).toContain('md:grid-cols-3')
})
it('has responsive text sizing', () => {
/**
* Test responsive typography.
*
* The main heading should scale appropriately on different
* screen sizes.
*/
const wrapper = mount(HomePage, {
global: {
stubs: { RouterLink: RouterLinkStub },
},
})
const h1 = wrapper.find('h1')
expect(h1.classes()).toContain('text-4xl')
expect(h1.classes()).toContain('md:text-5xl')
})
})
})

View File

@ -0,0 +1,368 @@
/**
* Tests for MatchPage.
*
* These tests verify the match page handles game connection states
* correctly with proper error handling and cleanup.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { ref } from 'vue'
import MatchPage from './MatchPage.vue'
import { useGameStore } from '@/stores/game'
import type { VisibleGameState } from '@/types/game'
// Mock vue-router
const mockPush = vi.fn()
const mockRouteParams = ref<{ id?: string }>({})
vi.mock('vue-router', () => ({
useRouter: () => ({
push: mockPush,
}),
useRoute: () => ({
params: mockRouteParams.value,
}),
}))
describe('MatchPage', () => {
beforeEach(() => {
setActivePinia(createPinia())
mockPush.mockReset()
mockRouteParams.value = {}
})
describe('mount with match ID', () => {
it('renders loading state when game not connected', () => {
/**
* Test initial loading state.
*
* When first joining a match, users should see a loading
* indicator while the connection is established.
*/
mockRouteParams.value = { id: 'match-123' }
const gameStore = useGameStore()
gameStore.gameState = null
const wrapper = mount(MatchPage)
expect(wrapper.text()).toContain('Connecting to match')
})
it('shows loading spinner animation', () => {
/**
* Test loading visual feedback.
*
* The loading state should have an animated spinner for
* better user experience.
*/
mockRouteParams.value = { id: 'match-123' }
const gameStore = useGameStore()
gameStore.gameState = null
const wrapper = mount(MatchPage)
const spinner = wrapper.find('.animate-pulse')
expect(spinner.exists()).toBe(true)
})
it('shows match UI when game is loaded', () => {
/**
* Test connected state.
*
* Once the game state is loaded, the match UI should appear.
*/
mockRouteParams.value = { id: 'match-123' }
const gameStore = useGameStore()
gameStore.gameState = {
matchId: 'match-123',
currentPlayer: 'player1',
myHand: [],
opponentHand: [],
} as Partial<VisibleGameState>
const wrapper = mount(MatchPage)
// Should show match UI placeholder
expect(wrapper.text()).toContain('Match UI coming in Phase F4')
})
})
describe('mount without match ID', () => {
it('redirects to campaign when no match ID', async () => {
/**
* Test error case: missing match ID.
*
* If the user navigates to /match without an ID, they should
* be redirected to the campaign page.
*/
mockRouteParams.value = {}
mount(MatchPage)
await flushPromises()
expect(mockPush).toHaveBeenCalledWith('/campaign')
})
it('redirects when match ID is undefined', async () => {
/**
* Test error case: undefined match ID.
*
* Explicitly undefined match IDs should also redirect.
*/
mockRouteParams.value = { id: undefined }
mount(MatchPage)
await flushPromises()
expect(mockPush).toHaveBeenCalledWith('/campaign')
})
it('does not redirect when match ID is provided', () => {
/**
* Test normal case: valid match ID.
*
* With a valid match ID, no redirect should occur.
*/
mockRouteParams.value = { id: 'match-123' }
mount(MatchPage)
expect(mockPush).not.toHaveBeenCalled()
})
})
describe('unmount cleanup', () => {
it('clears game state on unmount', () => {
/**
* Test cleanup on component unmount.
*
* When leaving the match page, the game state should be
* cleared to prevent stale data.
*/
mockRouteParams.value = { id: 'match-123' }
const gameStore = useGameStore()
gameStore.gameState = {
matchId: 'match-123',
currentPlayer: 'player1',
} as Partial<VisibleGameState>
const clearGameSpy = vi.spyOn(gameStore, 'clearGame')
const wrapper = mount(MatchPage)
wrapper.unmount()
expect(clearGameSpy).toHaveBeenCalled()
})
it('clears game state even if not connected', () => {
/**
* Test cleanup when game never loaded.
*
* Even if the game never connected, cleanup should still
* run to ensure no partial state remains.
*/
mockRouteParams.value = { id: 'match-123' }
const gameStore = useGameStore()
gameStore.gameState = null
const clearGameSpy = vi.spyOn(gameStore, 'clearGame')
const wrapper = mount(MatchPage)
wrapper.unmount()
expect(clearGameSpy).toHaveBeenCalled()
})
})
describe('edge cases', () => {
it('handles match ID as string', () => {
/**
* Test match ID type handling.
*
* Route params are always strings, ensure this is handled
* correctly.
*/
mockRouteParams.value = { id: 'match-abc-123' }
const wrapper = mount(MatchPage)
// Should not throw or redirect
expect(mockPush).not.toHaveBeenCalled()
expect(wrapper.exists()).toBe(true)
})
it('handles very long match IDs', () => {
/**
* Test long ID handling.
*
* UUIDs and other long IDs should be handled without issues.
*/
const longId = 'a'.repeat(200)
mockRouteParams.value = { id: longId }
const wrapper = mount(MatchPage)
// Should not throw or redirect
expect(mockPush).not.toHaveBeenCalled()
expect(wrapper.exists()).toBe(true)
})
it('handles special characters in match ID', () => {
/**
* Test ID sanitization.
*
* Match IDs with special characters should be handled safely.
*/
mockRouteParams.value = { id: 'match-123!@#$%' }
const wrapper = mount(MatchPage)
// Should not throw or redirect
expect(mockPush).not.toHaveBeenCalled()
expect(wrapper.exists()).toBe(true)
})
it('handles rapid mount/unmount cycles', () => {
/**
* Test cleanup resilience.
*
* Rapidly mounting and unmounting (e.g., during navigation)
* should not cause errors.
*/
mockRouteParams.value = { id: 'match-123' }
for (let i = 0; i < 5; i++) {
const wrapper = mount(MatchPage)
wrapper.unmount()
}
// Should not throw
expect(true).toBe(true)
})
})
describe('game state transitions', () => {
it('updates UI when game state changes from null to loaded', async () => {
/**
* Test reactive state updates.
*
* When the game state loads after mounting, the UI should
* update to show the match interface.
*/
mockRouteParams.value = { id: 'match-123' }
const gameStore = useGameStore()
gameStore.gameState = null
const wrapper = mount(MatchPage)
// Initially loading
expect(wrapper.text()).toContain('Connecting to match')
// Game loads
gameStore.gameState = {
matchId: 'match-123',
currentPlayer: 'player1',
} as Partial<VisibleGameState>
await wrapper.vm.$nextTick()
// Should show match UI
expect(wrapper.text()).toContain('Match UI coming in Phase F4')
expect(wrapper.text()).not.toContain('Connecting to match')
})
it('shows loading state when game state is cleared', async () => {
/**
* Test state clearing reactivity.
*
* If the game state is cleared (disconnect), the UI should
* return to loading state.
*/
mockRouteParams.value = { id: 'match-123' }
const gameStore = useGameStore()
gameStore.gameState = {
matchId: 'match-123',
currentPlayer: 'player1',
} as Partial<VisibleGameState>
const wrapper = mount(MatchPage)
// Initially showing match UI
expect(wrapper.text()).toContain('Match UI coming in Phase F4')
// Game state cleared (disconnect)
gameStore.gameState = null
await wrapper.vm.$nextTick()
// Should show loading state again
expect(wrapper.text()).toContain('Connecting to match')
})
})
describe('accessibility', () => {
it('loading state has descriptive text', () => {
/**
* Test loading accessibility.
*
* Screen reader users should know what's happening.
*/
mockRouteParams.value = { id: 'match-123' }
const gameStore = useGameStore()
gameStore.gameState = null
const wrapper = mount(MatchPage)
expect(wrapper.text()).toContain('Connecting to match')
})
it('uses semantic HTML structure', () => {
/**
* Test HTML semantics.
*
* The page should use proper div structure.
*/
mockRouteParams.value = { id: 'match-123' }
const wrapper = mount(MatchPage)
// Should have proper container divs
expect(wrapper.find('.h-screen').exists()).toBe(true)
})
})
describe('layout', () => {
it('uses full screen height', () => {
/**
* Test full-screen layout.
*
* Match pages should use the full viewport height.
*/
mockRouteParams.value = { id: 'match-123' }
const wrapper = mount(MatchPage)
const container = wrapper.find('.h-screen')
expect(container.exists()).toBe(true)
})
it('centers loading indicator', () => {
/**
* Test loading centering.
*
* The loading spinner should be centered in the viewport.
*/
mockRouteParams.value = { id: 'match-123' }
const gameStore = useGameStore()
gameStore.gameState = null
const wrapper = mount(MatchPage)
const container = wrapper.find('.h-screen')
expect(container.classes()).toContain('flex')
expect(container.classes()).toContain('items-center')
expect(container.classes()).toContain('justify-center')
})
})
})