From 63bcff8d9f37141c86a3d5ed9686ec9cd529e065 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Tue, 3 Feb 2026 10:12:29 -0600 Subject: [PATCH] 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 --- frontend/TEST_COVERAGE_PLAN.md | 8 +- .../components/deck/DeckActionButtons.spec.ts | 434 ++++++++++ .../src/components/deck/DeckCardRow.spec.ts | 812 ++++++++++++++++++ .../src/components/deck/DeckHeader.spec.ts | 398 +++++++++ frontend/src/composables/useDragDrop.spec.ts | 498 +++++++++++ frontend/src/pages/CampaignPage.spec.ts | 117 +++ frontend/src/pages/HomePage.spec.ts | 467 ++++++++++ frontend/src/pages/MatchPage.spec.ts | 368 ++++++++ 8 files changed, 3098 insertions(+), 4 deletions(-) create mode 100644 frontend/src/components/deck/DeckActionButtons.spec.ts create mode 100644 frontend/src/components/deck/DeckCardRow.spec.ts create mode 100644 frontend/src/components/deck/DeckHeader.spec.ts create mode 100644 frontend/src/pages/CampaignPage.spec.ts create mode 100644 frontend/src/pages/HomePage.spec.ts create mode 100644 frontend/src/pages/MatchPage.spec.ts diff --git a/frontend/TEST_COVERAGE_PLAN.md b/frontend/TEST_COVERAGE_PLAN.md index d391dbd..f3be499 100644 --- a/frontend/TEST_COVERAGE_PLAN.md +++ b/frontend/TEST_COVERAGE_PLAN.md @@ -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:** diff --git a/frontend/src/components/deck/DeckActionButtons.spec.ts b/frontend/src/components/deck/DeckActionButtons.spec.ts new file mode 100644 index 0000000..718cfd9 --- /dev/null +++ b/frontend/src/components/deck/DeckActionButtons.spec.ts @@ -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') + }) + }) +}) diff --git a/frontend/src/components/deck/DeckCardRow.spec.ts b/frontend/src/components/deck/DeckCardRow.spec.ts new file mode 100644 index 0000000..8a1f1dc --- /dev/null +++ b/frontend/src/components/deck/DeckCardRow.spec.ts @@ -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 { + 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') + }) + }) + }) +}) diff --git a/frontend/src/components/deck/DeckHeader.spec.ts b/frontend/src/components/deck/DeckHeader.spec.ts new file mode 100644 index 0000000..403a075 --- /dev/null +++ b/frontend/src/components/deck/DeckHeader.spec.ts @@ -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') + }) + }) +}) diff --git a/frontend/src/composables/useDragDrop.spec.ts b/frontend/src/composables/useDragDrop.spec.ts index 4efa33d..cb9688c 100644 --- a/frontend/src/composables/useDragDrop.spec.ts +++ b/frontend/src/composables/useDragDrop.spec.ts @@ -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() + }) + }) + }) }) diff --git a/frontend/src/pages/CampaignPage.spec.ts b/frontend/src/pages/CampaignPage.spec.ts new file mode 100644 index 0000000..e6cc878 --- /dev/null +++ b/frontend/src/pages/CampaignPage.spec.ts @@ -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() + }) + }) +}) diff --git a/frontend/src/pages/HomePage.spec.ts b/frontend/src/pages/HomePage.spec.ts new file mode 100644 index 0000000..7da8fbf --- /dev/null +++ b/frontend/src/pages/HomePage.spec.ts @@ -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: '', + 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') + }) + }) +}) diff --git a/frontend/src/pages/MatchPage.spec.ts b/frontend/src/pages/MatchPage.spec.ts new file mode 100644 index 0000000..ab543cb --- /dev/null +++ b/frontend/src/pages/MatchPage.spec.ts @@ -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 + + 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 + + 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 + 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 + + 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') + }) + }) +})