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:
parent
c5aef933e2
commit
63bcff8d9f
@ -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:**
|
||||
|
||||
434
frontend/src/components/deck/DeckActionButtons.spec.ts
Normal file
434
frontend/src/components/deck/DeckActionButtons.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
812
frontend/src/components/deck/DeckCardRow.spec.ts
Normal file
812
frontend/src/components/deck/DeckCardRow.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
398
frontend/src/components/deck/DeckHeader.spec.ts
Normal file
398
frontend/src/components/deck/DeckHeader.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
117
frontend/src/pages/CampaignPage.spec.ts
Normal file
117
frontend/src/pages/CampaignPage.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
467
frontend/src/pages/HomePage.spec.ts
Normal file
467
frontend/src/pages/HomePage.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
368
frontend/src/pages/MatchPage.spec.ts
Normal file
368
frontend/src/pages/MatchPage.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user