/** @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')])
})