/** @jsxImportSource vue */ import { expect, test, vi } from 'vitest' import { Hexboard } from '../lib' import { Hexchess } from '@bedard/hexchess' import { index, San } from '@bedard/hexchess' import { dragMove, clickMove, setup } from './utils' import { page } from 'vitest/browser' import { ref, nextTick } from 'vue' import { userEvent } from 'vitest/browser' test('update mouseover position on hover', async () => { const active = ref(false) const mouseover = ref(-1) setup(() => { return () => ( ) }) await page.getByTestId('position-f6').hover() await expect(mouseover.value).toBe(-1) active.value = true await nextTick() await page.getByTestId('position-f1').hover() await expect(mouseover.value).toBe(index('f1')) }) test('calls handler on position click', async () => { const active = ref(false) const onClickPosition = vi.fn() setup(() => { return () => ( ) }) await page.getByTestId('position-f6').click() await expect(onClickPosition).not.toHaveBeenCalled() active.value = true await nextTick() await page.getByTestId('position-f6').click() await expect(onClickPosition).toHaveBeenCalledOnce() await expect(onClickPosition).toHaveBeenCalledWith(index('f6')) }) test('flipped board', async () => { const flipped = ref(false) setup(() => { return () => }) const { y: startY } = page .getByTestId('position-f1') .element() .getBoundingClientRect() flipped.value = true await nextTick() const { y: endY } = page .getByTestId('position-f1') .element() .getBoundingClientRect() await expect(startY).toBeGreaterThan(endY) // f1 starts at the bottom, then moves to top }) test('custom colors', async () => { setup(() => { return () => ( ) }) await page.getByTestId('position-a6').hover() await expect(page.getByTestId('position-a6')).toHaveStyle({ fill: 'red' }) await page.getByTestId('position-b7').hover() await expect(page.getByTestId('position-b7')).toHaveStyle({ fill: 'green' }) await page.getByTestId('position-c8').hover() await expect(page.getByTestId('position-c8')).toHaveStyle({ fill: 'blue' }) }) test('labels and label colors', async () => { const active = ref(false) const labels = ref(false) setup(() => { return () => ( ) }) // Labels only show when enabled await expect.element(page.getByTestId('position-a1')).toBeInTheDocument() await expect.element(page.getByTestId('label-a')).not.toBeInTheDocument() labels.value = true await nextTick() await expect.element(page.getByTestId('label-a')).toBeVisible() // When no mouseover, all labels should have default labelColor (red) await expect .element(page.getByTestId('label-a')) .toHaveStyle({ fill: 'red' }) await expect .element(page.getByTestId('label-b')) .toHaveStyle({ fill: 'red' }) await expect .element(page.getByTestId('label-c')) .toHaveStyle({ fill: 'red' }) // No mouse events should be bound when inactive await page.getByTestId('position-f6').hover() await nextTick() await expect .element(page.getByTestId('label-a')) .toHaveStyle({ fill: 'red' }) await expect .element(page.getByTestId('label-b')) .toHaveStyle({ fill: 'red' }) await expect .element(page.getByTestId('label-c')) .toHaveStyle({ fill: 'red' }) // When hovering over f6, labels 'f' and '6' should be active (green) active.value = true await nextTick() await page.getByTestId('position-f5').hover() await expect .element(page.getByTestId('label-f')) .toHaveStyle({ fill: 'green' }) await expect .element(page.getByTestId('label-5').first()) .toHaveStyle({ fill: 'green' }) await expect .element(page.getByTestId('label-5').last()) .toHaveStyle({ fill: 'green' }) // Other labels should be inactive (blue) await expect .element(page.getByTestId('label-a')) .toHaveStyle({ fill: 'blue' }) await expect .element(page.getByTestId('label-1').first()) .toHaveStyle({ fill: 'blue' }) await expect .element(page.getByTestId('label-1').last()) .toHaveStyle({ fill: 'blue' }) }) test('targets array controls rendering of target circles', async () => { const targets = ref([]) setup(() => { return () => ( ) }) await expect.element(page.getByTestId('target-a1')).not.toBeInTheDocument() targets.value = [index('a1')] await expect.element(page.getByTestId('target-a1')).toBeVisible() await expect .element(page.getByTestId('target-a1')) .toHaveStyle({ fill: 'red' }) }) test('autoselect targets', async () => { setup(() => { return () => }) await page.getByTestId('position-f5').click() await expect.element(page.getByTestId('target-f6')).toBeVisible() }) test('select options and logic', async () => { const active = ref(false) const hexchess = ref(Hexchess.init()) const selected = ref(null) setup(() => { return () => ( <>
) }) // Initially, no selected path should be in the document await expect.element(page.getByTestId('selected-f6')).not.toBeInTheDocument() await expect.element(page.getByTestId('selected-a1')).not.toBeInTheDocument() // Clicking when inactive should not set selected await page.getByTestId('position-a1').click() await expect.element(page.getByTestId('assertion')).toHaveTextContent('') // // Activate the board active.value = true await nextTick() // Clicking when active should set selected await page.getByTestId('position-f5').click() await expect(selected.value).toBe(index('f5')) await expect.element(page.getByTestId('selected-f5')).toBeVisible() await expect .element(page.getByTestId('selected-f5')) .toHaveStyle({ fill: 'red' }) // Clicking an unoccupied position should deselect await page.getByTestId('position-a1').click() await expect.element(page.getByTestId('assertion')).toBeEmptyDOMElement() // Escape should clear selected await page.getByTestId('position-f5').click() await expect .element(page.getByTestId('assertion')) .toHaveTextContent(index('f5')) userEvent.keyboard('{Escape}') await expect.element(page.getByTestId('assertion')).toBeEmptyDOMElement() }) test('highlight array controls rendering of highlight paths', async () => { const highlight = ref([]) setup(() => { return () => ( ) }) // Initially, no highlight paths should be in the document await expect .element(page.getByTestId('highlight-f6')) .not.toBeInTheDocument() await expect .element(page.getByTestId('highlight-a1')) .not.toBeInTheDocument() // Setting highlight to a single position should render one path highlight.value = [index('f6')] await nextTick() await expect.element(page.getByTestId('highlight-f6')).toBeVisible() await expect .element(page.getByTestId('highlight-f6')) .toHaveStyle({ fill: 'pink' }) await expect .element(page.getByTestId('highlight-a1')) .not.toBeInTheDocument() // Setting highlight to multiple positions should render multiple paths highlight.value = [index('f6'), index('a1')] await nextTick() await expect.element(page.getByTestId('highlight-f6')).toBeVisible() await expect .element(page.getByTestId('highlight-f6')) .toHaveStyle({ fill: 'pink' }) await expect.element(page.getByTestId('highlight-a1')).toBeVisible() await expect .element(page.getByTestId('highlight-a1')) .toHaveStyle({ fill: 'pink' }) // Clearing highlight should remove all paths highlight.value = [] await nextTick() await expect .element(page.getByTestId('highlight-f6')) .not.toBeInTheDocument() await expect .element(page.getByTestId('highlight-a1')) .not.toBeInTheDocument() }) test('cursor shows grab for playable pieces', async () => { setup(() => { return () => }) const svg = page .getByTestId('position-f5') .element() .closest('svg') as SVGElement // Hover over a white piece (initial position has white to move) // f5 should have a white piece in initial position await page.getByTestId('position-f5').hover() await nextTick() await expect(svg).toHaveStyle({ cursor: 'grab' }) // White's turn, so grab }) test('cursor shows grab only when user can drag piece', async () => { setup(() => { return () => }) // Hover over a white piece when it's white's turn and user is playing white // Should show "grab" cursor await page.getByTestId('position-f5').hover() const svg = page .getByTestId('position-f5') .element() .closest('svg') as SVGElement await expect(svg).toHaveStyle({ cursor: 'grab' }) // Hover over an empty position when user is only playing white // Should show "auto" cursor (no piece to interact with) await page.getByTestId('position-a6').hover() await expect(svg).toHaveStyle({ cursor: 'auto' }) // Hover over a black piece when user is only playing white // Should show "pointer" cursor (can't drag black piece as white) await page.getByTestId('position-f7').hover() await expect(svg).toHaveStyle({ cursor: 'pointer' }) }) test('cursor behavior when playing both colors', async () => { setup(() => { return () => }) const svg = page .getByTestId('position-f5') .element() .closest('svg') as SVGElement // Initial position is white's turn, so white pieces show "grab" await page.getByTestId('position-f5').hover() await expect(svg).toHaveStyle({ cursor: 'grab' }) // Black pieces show "pointer" because it's not black's turn await page.getByTestId('position-b7').hover() await expect(svg).toHaveStyle({ cursor: 'grab' }) // Empty positions show "auto" because there's no piece await page.getByTestId('position-a6').hover() await expect(svg).toHaveStyle({ cursor: 'auto' }) }) test('cursor shows pointer when user is not playing', async () => { setup(() => { return () => }) // When playing is false, should show pointer for any piece await page.getByTestId('position-f5').hover() const svg = page .getByTestId('position-f5') .element() .closest('svg') as SVGElement await expect(svg).toHaveStyle({ cursor: 'pointer' }) await page.getByTestId('position-f7').hover() await expect(svg).toHaveStyle({ cursor: 'pointer' }) }) test('pieces of any color can be selected, but only playing color is draggable', async () => { const selected = ref(null) const targets = ref([]) setup(() => { return () => ( <>
) }) const svg = page .getByTestId('position-f5') .element() .closest('svg') as SVGElement // White piece shows grab cursor (user can drag) const whitePiecePosition = page.getByTestId('position-f5') await whitePiecePosition.hover() await expect(svg).toHaveStyle({ cursor: 'grab' }) // Black piece shows pointer cursor (user cannot drag) const blackPiecePosition = page.getByTestId('position-b7') await blackPiecePosition.hover() await expect(svg).toHaveStyle({ cursor: 'pointer' }) // Both pieces can be selected await whitePiecePosition.click() await expect.element(page.getByTestId('selected-f5')).toBeVisible() await expect .element(page.getByTestId('assertion')) .toHaveTextContent(index('f5')) await blackPiecePosition.click() await expect.element(page.getByTestId('selected-b7')).toBeVisible() await expect .element(page.getByTestId('assertion')) .toHaveTextContent(index('b7')) }) test('dragging piece off board results in selection only, dragging state resets', async () => { const selected = ref(null) const targets = ref([]) setup(() => { return () => ( <>
) }) const whitePiecePosition = page.getByTestId('position-f5') // Start dragging the piece (pointerdown) await whitePiecePosition .element() .dispatchEvent(new PointerEvent('pointerdown', { bubbles: true })) await nextTick() // Verify dragging started - draggable piece SVG should be visible await expect.element(page.getByTestId('drag-piece')).toBeVisible() // Move pointer off the board (simulate pointermove on window) window.dispatchEvent( new PointerEvent('pointermove', { clientX: 0, clientY: 0, bubbles: true }), ) await nextTick() // Release pointer (pointerup on window) - this should reset dragging state window.dispatchEvent(new PointerEvent('pointerup', { bubbles: true })) await nextTick() // Verify piece is still selected (autoselect should have set it on mousedown) await expect.element(page.getByTestId('selected-f5')).toBeVisible() await expect .element(page.getByTestId('assertion')) .toHaveTextContent(index('f5')) // Verify dragging state is reset - draggable piece SVG should no longer exist await expect.element(page.getByTestId('drag-piece')).not.toBeInTheDocument() }) test('drag emits dragstartPosition and dragendPosition', async () => { const selected = ref(null) const targets = ref([]) const onDragstartPosition = vi.fn() const onDragendPosition = vi.fn() setup(() => { return () => ( ) }) await dragMove(page, 'f5f6') await expect(onDragstartPosition).toHaveBeenCalledOnce() await expect(onDragstartPosition).toHaveBeenCalledWith(index('f5')) await expect(onDragendPosition).toHaveBeenCalledOnce() await expect(onDragendPosition).toHaveBeenCalledWith(index('f6')) }) test('drag start emits dragstartPosition; release off board does not emit dragendPosition', async () => { const selected = ref(null) const targets = ref([]) const onDragstartPosition = vi.fn() const onDragendPosition = vi.fn() setup(() => { return () => ( ) }) const whitePiecePosition = page.getByTestId('position-f5') await whitePiecePosition .element() .dispatchEvent(new PointerEvent('pointerdown', { bubbles: true })) await nextTick() await expect(onDragstartPosition).toHaveBeenCalledOnce() await expect(onDragstartPosition).toHaveBeenCalledWith(index('f5')) await expect(onDragendPosition).not.toHaveBeenCalled() window.dispatchEvent(new PointerEvent('pointerup', { bubbles: true })) await nextTick() await expect(onDragstartPosition).toHaveBeenCalledOnce() await expect(onDragendPosition).not.toHaveBeenCalled() }) test('drag and drop piece emits move event', async () => { const selected = ref(null) const targets = ref([]) const onMove = vi.fn() setup(() => { return () => ( ) }) await dragMove(page, 'f5f6') // Verify move event was emitted with correct San object await expect(onMove).toHaveBeenCalledOnce() await expect(onMove).toHaveBeenCalledWith(expect.any(San)) const san = onMove.mock.calls[0][0] as San await expect(san.from).toBe(index('f5')) await expect(san.to).toBe(index('f6')) }) test('click to move piece emits move event', async () => { const selected = ref(null) const targets = ref([]) const onMove = vi.fn() setup(() => { return () => ( ) }) // Click on a piece to select it (f5 should have a white piece) await page.getByTestId('position-f5').click() await nextTick() // Verify piece is selected and targets are shown await expect.element(page.getByTestId('selected-f5')).toBeVisible() await expect.element(page.getByTestId('target-f6')).toBeVisible() // Click on a target position to move await page.getByTestId('position-f6').click() await nextTick() // Verify move event was emitted with correct San object await expect(onMove).toHaveBeenCalledOnce() await expect(onMove).toHaveBeenCalledWith(expect.any(San)) const san = onMove.mock.calls[0][0] as San await expect(san.from).toBe(index('f5')) await expect(san.to).toBe(index('f6')) }) test('cannot move piece of other turn color', async () => { const selected = ref(null) const targets = ref([]) const onMove = vi.fn() setup(() => { return () => ( ) }) // Initial position is white's turn, so try to move a black piece // Click on a black piece (b7 should have a black rook in initial position) await page.getByTestId('position-b7').click() await nextTick() // Verify piece is selected and targets are shown await expect.element(page.getByTestId('selected-b7')).toBeVisible() await expect.element(page.getByTestId('target-b6')).toBeVisible() // Try to click on a target position to move await page.getByTestId('position-b6').click() await nextTick() // Verify move event was NOT called (can't move black piece on white's turn) await expect(onMove).not.toHaveBeenCalled() }) test('promotion', async () => { setup(() => { const hexchess = ref( Hexchess.parse('1/1P1/5/7/9/11/11/11/11/11/11 w - 0 1'), ) return () => ( hexchess.value.applyMoveUnsafe(san)} > {{ promotion: ({ promote }: any) => ( ), }} ) }) await clickMove(page, 'f10f11') await page.getByTestId('promote').click() await expect(page.getByTestId('piece-f11')).toHaveAttribute( 'data-piece-type', 'Q', ) }) test('canceled promotion', async () => { const selected = ref(null) const onMove = vi.fn() setup(() => { const hexchess = ref( Hexchess.parse('1/1P1/5/7/9/11/11/11/11/11/11 w - 0 1'), ) return () => ( {{ promotion: ({ cancel }: any) => ( ), }} ) }) // Move pawn to promotion square await clickMove(page, 'f10f11') // Promotion UI should be visible await expect.element(page.getByTestId('cancel')).toBeVisible() // Cancel the promotion await page.getByTestId('cancel').click() await nextTick() // Move should not have been emitted await expect(onMove).not.toHaveBeenCalled() // Original piece should still be selected await expect(selected.value).toBe(index('f10')) await expect.element(page.getByTestId('selected-f10')).toBeVisible() // Pawn should still be at original position await expect .element(page.getByTestId('piece-f10')) .toHaveAttribute('data-piece-type', 'P') }) test('clicking position during promotion cancels it', async () => { const selected = ref(null) const onMove = vi.fn() setup(() => { const hexchess = ref( Hexchess.parse('1/1P1/5/7/9/11/11/11/11/11/11 w - 0 1'), ) return () => ( {{ promotion: () => , }} ) }) // Move pawn to promotion square await clickMove(page, 'f10f11') // Promotion UI should be visible await expect.element(page.getByTestId('promote')).toBeVisible() // Click on a different position to cancel the promotion await page.getByTestId('position-a1').click() await nextTick() // Promotion UI should be gone await expect.element(page.getByTestId('promote')).not.toBeInTheDocument() // Move should not have been emitted await expect(onMove).not.toHaveBeenCalled() // Original piece should still be selected await expect.element(page.getByTestId('selected-f10')).toBeVisible() // Pawn should still be at original position await expect .element(page.getByTestId('piece-f10')) .toHaveAttribute('data-piece-type', 'P') }) test('ignoreTurn allows moving pieces out of turn', async () => { const ignoreTurn = ref(false) const onMove = vi.fn() setup(() => { return () => ( ) }) // Initial position is white's turn, try to move black's pawn without ignoreTurn await clickMove(page, 'f7f6') await expect(onMove).not.toHaveBeenCalled() // Enable ignoreTurn and try again ignoreTurn.value = true await nextTick() await clickMove(page, 'f7f6') await expect(onMove).toHaveBeenCalledOnce() await expect(onMove).toHaveBeenCalledWith( expect.objectContaining({ from: index('f7'), to: index('f6'), }), ) }) test('dragging piece to non-target position keeps selection', async () => { const selected = ref(null) const targets = ref([]) const onMove = vi.fn() setup(() => { return () => ( <>
) }) await dragMove(page, 'f5a1') // Piece should still be selected await expect.element(page.getByTestId('selected-f5')).toBeVisible() await expect(selected.value).toBe(index('f5')) // No move should have been emitted await expect(onMove).not.toHaveBeenCalled() }) test('click capture', async () => { const hexchess = ref( Hexchess.parse('b/qbk/n1b1n/r5r/ppp1ppppp/11/4pP5/4P1P4/3P1B1P3/2P2B2P2/1PRNQBKNRP1 w e6 0 2'), ) setup(() => { return () => ( hexchess.value.applyMoveUnsafe(san)} /> ) }) await clickMove(page, 'f5e5') await expect(hexchess.value.toString()).toBe('b/qbk/n1b1n/r5r/ppp1ppppp/11/4P6/4P1P4/3P1B1P3/2P2B2P2/1PRNQBKNRP1 b - 0 2') }) test('drag capture', async () => { const hexchess = ref( Hexchess.parse('b/qbk/n1b1n/r5r/ppp1ppppp/11/4pP5/4P1P4/3P1B1P3/2P2B2P2/1PRNQBKNRP1 w e6 0 2'), ) setup(() => { return () => ( hexchess.value.applyMoveUnsafe(san)} /> ) }) await dragMove(page, 'f5e5') await expect(hexchess.value.toString()).toBe('b/qbk/n1b1n/r5r/ppp1ppppp/11/4P6/4P1P4/3P1B1P3/2P2B2P2/1PRNQBKNRP1 b - 0 2') }) test('updates targets when autoselect is true', async () => { const selected = ref(index('f5')) const targets = ref([]) setup(() => { return () => ( ) }) await expect(targets.value).toEqual([index('f6')]) selected.value = index('e4') await nextTick() await expect(targets.value).toEqual([index('e5'), index('e6')]) })