// @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest'; import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'; import ScannerHistoryStrip, { TAB_DUPLICATES, TAB_QUEUE, TAB_RECENT, getDuplicateCards, } from '../../components/scanner/ScannerHistoryStrip.js'; const CARD_A = { id: 'card-a', name: 'Lightning Bolt', set: 'Alpha', databaseId: 101, quantity: 1, processed: false, }; const CARD_B = { id: 'card-b', name: 'Lightning Bolt', set: 'Alpha', databaseId: 102, quantity: 1, processed: false, }; const CARD_C = { id: 'card-c', name: 'Counterspell', set: 'Beta', databaseId: 201, quantity: 2, processed: true, }; function renderStrip(overrides = {}) { const props = { scannedCards: [], ownershipMap: {}, focusedCardId: null, onFocusCard: vi.fn(), activeTab: TAB_RECENT, onTabChange: vi.fn(), batchProgress: null, onClearAll: vi.fn(), onCommitSelectedToOwned: vi.fn(), onOpenListPicker: vi.fn(), isProcessing: false, ...overrides, }; return { ...render(), props, }; } describe('getDuplicateCards', () => { it('flags session name/set repeats', () => { const result = getDuplicateCards([CARD_A, CARD_B], {}); expect(result.map((card) => card.id)).toEqual(['card-b']); }); it('flags quantity greater than one', () => { const solo = { ...CARD_C, quantity: 2 }; const result = getDuplicateCards([solo], {}); expect(result).toHaveLength(1); expect(result[0].id).toBe('card-c'); }); it('flags cards already in ownershipMap by databaseId', () => { const owned = { ...CARD_A, databaseId: 999 }; const result = getDuplicateCards([owned], { 999: { quantity: 1 } }); expect(result).toHaveLength(1); expect(result[0].id).toBe('card-a'); }); }); describe('ScannerHistoryStrip', () => { afterEach(() => cleanup()); it('renders three tabs with badge counts in aria-label', () => { renderStrip({ scannedCards: [CARD_A, CARD_B, CARD_C], activeTab: TAB_RECENT, }); expect(screen.getByRole('tab', { name: 'Recent Scans' })).toBeTruthy(); expect(screen.getByRole('tab', { name: 'Scan Queue, 2 unprocessed cards' })).toBeTruthy(); expect(screen.getByRole('tab', { name: 'Duplicates, 2 duplicate cards' })).toBeTruthy(); expect(screen.getByRole('tab', { name: 'Scan Queue, 2 unprocessed cards' }).innerHTML).not.toContain('#ffffff'); }); it('calls onTabChange when a tab is clicked', () => { const { props } = renderStrip({ scannedCards: [CARD_A] }); fireEvent.click(screen.getByRole('tab', { name: /scan queue/i })); expect(props.onTabChange).toHaveBeenCalledWith(TAB_QUEUE); }); it('shows empty states for each tab', () => { const { rerender, props } = renderStrip({ activeTab: TAB_RECENT }); expect(screen.getByText('No scans yet this session.')).toBeTruthy(); rerender(); expect( screen.getByText('Scan queue is empty — matches appear here before you add them.') ).toBeTruthy(); rerender(); expect(screen.getByText('No duplicates detected.')).toBeTruthy(); }); it('row click invokes onFocusCard without commit handlers', () => { const onFocusCard = vi.fn(); const onCommitSelectedToOwned = vi.fn(); const onOpenListPicker = vi.fn(); renderStrip({ scannedCards: [CARD_A], activeTab: TAB_RECENT, onFocusCard, onCommitSelectedToOwned, onOpenListPicker, }); fireEvent.click(screen.getByRole('button', { name: /lightning bolt/i })); expect(onFocusCard).toHaveBeenCalledWith('card-a'); expect(onCommitSelectedToOwned).not.toHaveBeenCalled(); expect(onOpenListPicker).not.toHaveBeenCalled(); }); it('applies focused styling when focusedCardId matches a chip', () => { renderStrip({ scannedCards: [CARD_A], activeTab: TAB_RECENT, focusedCardId: 'card-a', }); const chip = screen.getByRole('button', { name: /lightning bolt/i }); expect(chip.getAttribute('aria-current')).toBe('true'); }); it('shows batch progress banner with cancel in Scan Queue tab', () => { const onCancel = vi.fn(); renderStrip({ scannedCards: [CARD_A], activeTab: TAB_QUEUE, batchProgress: { active: true, current: 2, total: 5, onCancel }, }); expect(screen.getByText('Scanning 2 of 5…')).toBeTruthy(); fireEvent.click(screen.getByRole('button', { name: /^cancel$/i })); expect(onCancel).toHaveBeenCalledTimes(1); }); it('disables Clear All while batch progress is active', () => { renderStrip({ scannedCards: [CARD_A], batchProgress: { active: true, current: 1, total: 3, onCancel: vi.fn() }, }); expect(screen.getByRole('button', { name: /clear all/i }).disabled).toBe(true); }); it('shows identify failed label on failed queue rows', () => { const failedCard = { ...CARD_A, identifyFailed: true, identifyFailureReason: 'No match found', }; renderStrip({ scannedCards: [failedCard], activeTab: TAB_QUEUE, }); expect(screen.getByText('Identify failed')).toBeTruthy(); expect(screen.getByText('No match found')).toBeTruthy(); }); it('lists recent scans newest first', () => { const older = { ...CARD_A, id: 'older', name: 'Older Card' }; const newer = { ...CARD_B, id: 'newer', name: 'Newer Card', set: 'New Set' }; renderStrip({ scannedCards: [older, newer], activeTab: TAB_RECENT, }); const panel = screen.getByRole('tabpanel', { name: /recent scans/i }); const buttons = within(panel).getAllByRole('button'); expect(buttons[0].textContent).toContain('Newer Card'); expect(buttons[1].textContent).toContain('Older Card'); }); it('exposes tablist semantics', () => { renderStrip({ scannedCards: [CARD_A] }); expect(screen.getByRole('tablist', { name: 'Scan history' })).toBeTruthy(); expect(screen.getAllByRole('tab')).toHaveLength(3); expect(screen.getAllByRole('tabpanel', { hidden: true })).toHaveLength(3); }); });