@bedard/hexboard 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/.github/workflows/build.yml +65 -0
  2. package/.github/workflows/release.yml +0 -0
  3. package/.vscode/settings.json +5 -0
  4. package/LICENSE +21 -0
  5. package/README.md +8 -0
  6. package/dist/index.d.ts +127 -0
  7. package/dist/index.js +1056 -0
  8. package/eslint.config.js +85 -0
  9. package/index.html +22 -0
  10. package/package.json +58 -0
  11. package/src/lib/components/hexboard/Hexboard.vue +865 -0
  12. package/src/lib/components/hexboard/constants.ts +389 -0
  13. package/src/lib/components/hexboard/dom.ts +19 -0
  14. package/src/lib/components/hexboard/geometry.ts +59 -0
  15. package/src/lib/components/hexboard/haptics.ts +56 -0
  16. package/src/lib/components/hexboard/pieces/Celtic.vue +22 -0
  17. package/src/lib/components/hexboard/pieces/Fantasy.vue +22 -0
  18. package/src/lib/components/hexboard/pieces/Gioco.vue +22 -0
  19. package/src/lib/components/hexboard/pieces/Spatial.vue +22 -0
  20. package/src/lib/components/hexboard/pieces/index.ts +4 -0
  21. package/src/lib/components/hexboard/types.ts +28 -0
  22. package/src/lib/index.ts +1 -0
  23. package/src/sandbox/App.vue +28 -0
  24. package/src/sandbox/components/Button.vue +8 -0
  25. package/src/sandbox/components/icons/Github.vue +3 -0
  26. package/src/sandbox/components/icons/Menu.vue +3 -0
  27. package/src/sandbox/components/icons/X.vue +3 -0
  28. package/src/sandbox/index.ts +5 -0
  29. package/src/sandbox/tailwind.css +59 -0
  30. package/src/sandbox/views/HomeToolbar.vue +80 -0
  31. package/src/tests/example.test.tsx +18 -0
  32. package/src/tests/hexboard.test.tsx +832 -0
  33. package/src/tests/utils.ts +26 -0
  34. package/tsconfig.json +30 -0
  35. package/tsconfig.node.json +10 -0
  36. package/vite.config.ts +42 -0
  37. package/vite.sandbox.config.ts +21 -0
  38. package/vitest.config.ts +35 -0
@@ -0,0 +1,832 @@
1
+ /** @jsxImportSource vue */
2
+ import { expect, test, vi } from 'vitest'
3
+ import { Hexboard } from '../lib'
4
+ import { Hexchess } from '@bedard/hexchess'
5
+ import { index, San } from '@bedard/hexchess'
6
+ import { makeMove, setup } from './utils'
7
+ import { page } from 'vitest/browser'
8
+ import { ref, nextTick } from 'vue'
9
+ import { userEvent } from 'vitest/browser'
10
+
11
+ test('update mouseover position on hover', async () => {
12
+ const active = ref(false)
13
+ const mouseover = ref(-1)
14
+
15
+ setup(() => {
16
+ return () => (
17
+ <Hexboard
18
+ active={active.value}
19
+ v-model:mouseover-position={mouseover.value}
20
+ />
21
+ )
22
+ })
23
+
24
+ await page.getByTestId('position-f6').hover()
25
+ await expect(mouseover.value).toBe(-1)
26
+
27
+ active.value = true
28
+ await nextTick()
29
+
30
+ await page.getByTestId('position-f1').hover()
31
+ await expect(mouseover.value).toBe(index('f1'))
32
+ })
33
+
34
+ test('calls handler on position click', async () => {
35
+ const active = ref(false)
36
+ const onClickPosition = vi.fn()
37
+
38
+ setup(() => {
39
+ return () => (
40
+ <Hexboard active={active.value} onClickPosition={onClickPosition} />
41
+ )
42
+ })
43
+
44
+ await page.getByTestId('position-f6').click()
45
+ await expect(onClickPosition).not.toHaveBeenCalled()
46
+
47
+ active.value = true
48
+ await nextTick()
49
+
50
+ await page.getByTestId('position-f6').click()
51
+ await expect(onClickPosition).toHaveBeenCalledOnce()
52
+ await expect(onClickPosition).toHaveBeenCalledWith(index('f6'))
53
+ })
54
+
55
+ test('flipped board', async () => {
56
+ const flipped = ref(false)
57
+
58
+ setup(() => {
59
+ return () => <Hexboard flipped={flipped.value} />
60
+ })
61
+
62
+ const { y: startY } = page
63
+ .getByTestId('position-f1')
64
+ .element()
65
+ .getBoundingClientRect()
66
+
67
+ flipped.value = true
68
+
69
+ await nextTick()
70
+
71
+ const { y: endY } = page
72
+ .getByTestId('position-f1')
73
+ .element()
74
+ .getBoundingClientRect()
75
+
76
+ await expect(startY).toBeGreaterThan(endY) // f1 starts at the bottom, then moves to top
77
+ })
78
+
79
+ test('custom colors', async () => {
80
+ setup(() => {
81
+ return () => (
82
+ <Hexboard
83
+ options={{
84
+ colors: ['red', 'green', 'blue'],
85
+ }}
86
+ />
87
+ )
88
+ })
89
+
90
+ await page.getByTestId('position-a6').hover()
91
+ await expect(page.getByTestId('position-a6')).toHaveStyle({ fill: 'red' })
92
+
93
+ await page.getByTestId('position-b7').hover()
94
+ await expect(page.getByTestId('position-b7')).toHaveStyle({ fill: 'green' })
95
+
96
+ await page.getByTestId('position-c8').hover()
97
+ await expect(page.getByTestId('position-c8')).toHaveStyle({ fill: 'blue' })
98
+ })
99
+
100
+ test('labels and label colors', async () => {
101
+ const active = ref(false)
102
+ const labels = ref(false)
103
+
104
+ setup(() => {
105
+ return () => (
106
+ <Hexboard
107
+ active={active.value}
108
+ options={{
109
+ labels: labels.value,
110
+ labelColor: 'red',
111
+ labelActiveColor: 'green',
112
+ labelInactiveColor: 'blue',
113
+ }}
114
+ />
115
+ )
116
+ })
117
+
118
+ // Labels only show when enabled
119
+ await expect.element(page.getByTestId('position-a1')).toBeInTheDocument()
120
+ await expect.element(page.getByTestId('label-a')).not.toBeInTheDocument()
121
+ labels.value = true
122
+ await nextTick()
123
+ await expect.element(page.getByTestId('label-a')).toBeVisible()
124
+
125
+ // When no mouseover, all labels should have default labelColor (red)
126
+ await expect
127
+ .element(page.getByTestId('label-a'))
128
+ .toHaveStyle({ fill: 'red' })
129
+ await expect
130
+ .element(page.getByTestId('label-b'))
131
+ .toHaveStyle({ fill: 'red' })
132
+ await expect
133
+ .element(page.getByTestId('label-c'))
134
+ .toHaveStyle({ fill: 'red' })
135
+
136
+ // No mouse events should be bound when inactive
137
+ await page.getByTestId('position-f6').hover()
138
+ await nextTick()
139
+ await expect
140
+ .element(page.getByTestId('label-a'))
141
+ .toHaveStyle({ fill: 'red' })
142
+ await expect
143
+ .element(page.getByTestId('label-b'))
144
+ .toHaveStyle({ fill: 'red' })
145
+ await expect
146
+ .element(page.getByTestId('label-c'))
147
+ .toHaveStyle({ fill: 'red' })
148
+
149
+ // When hovering over f6, labels 'f' and '6' should be active (green)
150
+ active.value = true
151
+ await nextTick()
152
+ await page.getByTestId('position-f5').hover()
153
+ await expect
154
+ .element(page.getByTestId('label-f'))
155
+ .toHaveStyle({ fill: 'green' })
156
+ await expect
157
+ .element(page.getByTestId('label-5').first())
158
+ .toHaveStyle({ fill: 'green' })
159
+ await expect
160
+ .element(page.getByTestId('label-5').last())
161
+ .toHaveStyle({ fill: 'green' })
162
+
163
+ // Other labels should be inactive (blue)
164
+ await expect
165
+ .element(page.getByTestId('label-a'))
166
+ .toHaveStyle({ fill: 'blue' })
167
+
168
+ await expect
169
+ .element(page.getByTestId('label-1').first())
170
+ .toHaveStyle({ fill: 'blue' })
171
+ await expect
172
+ .element(page.getByTestId('label-1').last())
173
+ .toHaveStyle({ fill: 'blue' })
174
+ })
175
+
176
+ test('targets array controls rendering of target circles', async () => {
177
+ const targets = ref<number[]>([])
178
+
179
+ setup(() => {
180
+ return () => (
181
+ <Hexboard
182
+ active
183
+ targets={targets.value}
184
+ options={{
185
+ targetColor: 'red',
186
+ }}
187
+ />
188
+ )
189
+ })
190
+
191
+ await expect.element(page.getByTestId('target-a1')).not.toBeInTheDocument()
192
+
193
+ targets.value = [index('a1')]
194
+
195
+ await expect.element(page.getByTestId('target-a1')).toBeVisible()
196
+ await expect
197
+ .element(page.getByTestId('target-a1'))
198
+ .toHaveStyle({ fill: 'red' })
199
+ })
200
+
201
+ test('autoselect targets', async () => {
202
+ setup(() => {
203
+ return () => <Hexboard active autoselect playing />
204
+ })
205
+
206
+ await page.getByTestId('position-f5').click()
207
+ await expect.element(page.getByTestId('target-f6')).toBeVisible()
208
+ })
209
+
210
+ test('select options and logic', async () => {
211
+ const active = ref(false)
212
+ const hexchess = ref(Hexchess.init())
213
+ const selected = ref<number | null>(null)
214
+
215
+ setup(() => {
216
+ return () => (
217
+ <>
218
+ <Hexboard
219
+ active={active.value}
220
+ autoselect
221
+ hexchess={hexchess.value}
222
+ playing
223
+ v-model:selected={selected.value}
224
+ options={{
225
+ selectedColor: 'red',
226
+ }}
227
+ />
228
+
229
+ <div v-text={selected.value} data-testid="assertion" />
230
+ </>
231
+ )
232
+ })
233
+
234
+ // Initially, no selected path should be in the document
235
+ await expect.element(page.getByTestId('selected-f6')).not.toBeInTheDocument()
236
+ await expect.element(page.getByTestId('selected-a1')).not.toBeInTheDocument()
237
+
238
+ // Clicking when inactive should not set selected
239
+ await page.getByTestId('position-a1').click()
240
+ await expect.element(page.getByTestId('assertion')).toHaveTextContent('')
241
+
242
+ // // Activate the board
243
+ active.value = true
244
+ await nextTick()
245
+
246
+ // Clicking when active should set selected
247
+ await page.getByTestId('position-f5').click()
248
+ await expect(selected.value).toBe(index('f5'))
249
+ await expect.element(page.getByTestId('selected-f5')).toBeVisible()
250
+ await expect
251
+ .element(page.getByTestId('selected-f5'))
252
+ .toHaveStyle({ fill: 'red' })
253
+
254
+ // Clicking an unoccupied position should deselect
255
+ await page.getByTestId('position-a1').click()
256
+ await expect.element(page.getByTestId('assertion')).toBeEmptyDOMElement()
257
+
258
+ // Escape should clear selected
259
+ await page.getByTestId('position-f5').click()
260
+ await expect
261
+ .element(page.getByTestId('assertion'))
262
+ .toHaveTextContent(index('f5'))
263
+ userEvent.keyboard('{Escape}')
264
+ await expect.element(page.getByTestId('assertion')).toBeEmptyDOMElement()
265
+ })
266
+
267
+ test('highlight array controls rendering of highlight paths', async () => {
268
+ const highlight = ref<number[]>([])
269
+
270
+ setup(() => {
271
+ return () => (
272
+ <Hexboard
273
+ highlight={highlight.value}
274
+ options={{
275
+ highlightColor: 'pink',
276
+ }}
277
+ />
278
+ )
279
+ })
280
+
281
+ // Initially, no highlight paths should be in the document
282
+ await expect
283
+ .element(page.getByTestId('highlight-f6'))
284
+ .not.toBeInTheDocument()
285
+ await expect
286
+ .element(page.getByTestId('highlight-a1'))
287
+ .not.toBeInTheDocument()
288
+
289
+ // Setting highlight to a single position should render one path
290
+ highlight.value = [index('f6')]
291
+ await nextTick()
292
+ await expect.element(page.getByTestId('highlight-f6')).toBeVisible()
293
+ await expect
294
+ .element(page.getByTestId('highlight-f6'))
295
+ .toHaveStyle({ fill: 'pink' })
296
+ await expect
297
+ .element(page.getByTestId('highlight-a1'))
298
+ .not.toBeInTheDocument()
299
+
300
+ // Setting highlight to multiple positions should render multiple paths
301
+ highlight.value = [index('f6'), index('a1')]
302
+ await nextTick()
303
+ await expect.element(page.getByTestId('highlight-f6')).toBeVisible()
304
+ await expect
305
+ .element(page.getByTestId('highlight-f6'))
306
+ .toHaveStyle({ fill: 'pink' })
307
+ await expect.element(page.getByTestId('highlight-a1')).toBeVisible()
308
+ await expect
309
+ .element(page.getByTestId('highlight-a1'))
310
+ .toHaveStyle({ fill: 'pink' })
311
+
312
+ // Clearing highlight should remove all paths
313
+ highlight.value = []
314
+ await nextTick()
315
+ await expect
316
+ .element(page.getByTestId('highlight-f6'))
317
+ .not.toBeInTheDocument()
318
+ await expect
319
+ .element(page.getByTestId('highlight-a1'))
320
+ .not.toBeInTheDocument()
321
+ })
322
+
323
+ test('cursor shows grab for playable pieces', async () => {
324
+ setup(() => {
325
+ return () => <Hexboard active playing />
326
+ })
327
+
328
+ const svg = page
329
+ .getByTestId('position-f5')
330
+ .element()
331
+ .closest('svg') as SVGElement
332
+
333
+ // Hover over a white piece (initial position has white to move)
334
+ // f5 should have a white piece in initial position
335
+ await page.getByTestId('position-f5').hover()
336
+ await nextTick()
337
+ await expect(svg).toHaveStyle({ cursor: 'grab' }) // White's turn, so grab
338
+ })
339
+
340
+ test('cursor shows grab only when user can drag piece', async () => {
341
+ setup(() => {
342
+ return () => <Hexboard active playing="w" />
343
+ })
344
+
345
+ // Hover over a white piece when it's white's turn and user is playing white
346
+ // Should show "grab" cursor
347
+ await page.getByTestId('position-f5').hover()
348
+ const svg = page
349
+ .getByTestId('position-f5')
350
+ .element()
351
+ .closest('svg') as SVGElement
352
+ await expect(svg).toHaveStyle({ cursor: 'grab' })
353
+
354
+ // Hover over an empty position when user is only playing white
355
+ // Should show "auto" cursor (no piece to interact with)
356
+ await page.getByTestId('position-a6').hover()
357
+ await expect(svg).toHaveStyle({ cursor: 'auto' })
358
+
359
+ // Hover over a black piece when user is only playing white
360
+ // Should show "pointer" cursor (can't drag black piece as white)
361
+ await page.getByTestId('position-f7').hover()
362
+ await expect(svg).toHaveStyle({ cursor: 'pointer' })
363
+ })
364
+
365
+ test('cursor behavior when playing both colors', async () => {
366
+ setup(() => {
367
+ return () => <Hexboard active playing />
368
+ })
369
+
370
+ const svg = page
371
+ .getByTestId('position-f5')
372
+ .element()
373
+ .closest('svg') as SVGElement
374
+
375
+ // Initial position is white's turn, so white pieces show "grab"
376
+ await page.getByTestId('position-f5').hover()
377
+ await expect(svg).toHaveStyle({ cursor: 'grab' })
378
+
379
+ // Black pieces show "pointer" because it's not black's turn
380
+ await page.getByTestId('position-b7').hover()
381
+ await expect(svg).toHaveStyle({ cursor: 'grab' })
382
+
383
+ // Empty positions show "auto" because there's no piece
384
+ await page.getByTestId('position-a6').hover()
385
+ await expect(svg).toHaveStyle({ cursor: 'auto' })
386
+ })
387
+
388
+ test('cursor shows pointer when user is not playing', async () => {
389
+ setup(() => {
390
+ return () => <Hexboard active playing={false} />
391
+ })
392
+
393
+ // When playing is false, should show pointer for any piece
394
+ await page.getByTestId('position-f5').hover()
395
+ const svg = page
396
+ .getByTestId('position-f5')
397
+ .element()
398
+ .closest('svg') as SVGElement
399
+ await expect(svg).toHaveStyle({ cursor: 'pointer' })
400
+
401
+ await page.getByTestId('position-f7').hover()
402
+ await expect(svg).toHaveStyle({ cursor: 'pointer' })
403
+ })
404
+
405
+ test('pieces of any color can be selected, but only playing color is draggable', async () => {
406
+ const selected = ref<number | null>(null)
407
+ const targets = ref<number[]>([])
408
+
409
+ setup(() => {
410
+ return () => (
411
+ <>
412
+ <Hexboard
413
+ active
414
+ autoselect
415
+ playing="w"
416
+ v-model:selected={selected.value}
417
+ v-model:targets={targets.value}
418
+ />
419
+
420
+ <div v-text={selected.value} data-testid="assertion" />
421
+ </>
422
+ )
423
+ })
424
+
425
+ const svg = page
426
+ .getByTestId('position-f5')
427
+ .element()
428
+ .closest('svg') as SVGElement
429
+
430
+ // White piece shows grab cursor (user can drag)
431
+ const whitePiecePosition = page.getByTestId('position-f5')
432
+ await whitePiecePosition.hover()
433
+ await expect(svg).toHaveStyle({ cursor: 'grab' })
434
+
435
+ // Black piece shows pointer cursor (user cannot drag)
436
+ const blackPiecePosition = page.getByTestId('position-b7')
437
+ await blackPiecePosition.hover()
438
+ await expect(svg).toHaveStyle({ cursor: 'pointer' })
439
+
440
+ // Both pieces can be selected
441
+ await whitePiecePosition.click()
442
+ await expect.element(page.getByTestId('selected-f5')).toBeVisible()
443
+ await expect
444
+ .element(page.getByTestId('assertion'))
445
+ .toHaveTextContent(index('f5'))
446
+
447
+ await blackPiecePosition.click()
448
+ await expect.element(page.getByTestId('selected-b7')).toBeVisible()
449
+ await expect
450
+ .element(page.getByTestId('assertion'))
451
+ .toHaveTextContent(index('b7'))
452
+ })
453
+
454
+ test('dragging piece off board results in selection only, dragging state resets', async () => {
455
+ const selected = ref<number | null>(null)
456
+ const targets = ref<number[]>([])
457
+
458
+ setup(() => {
459
+ return () => (
460
+ <>
461
+ <Hexboard
462
+ active
463
+ autoselect
464
+ playing="w"
465
+ v-model:selected={selected.value}
466
+ v-model:targets={targets.value}
467
+ />
468
+
469
+ <div v-text={selected.value} data-testid="assertion" />
470
+ </>
471
+ )
472
+ })
473
+
474
+ const whitePiecePosition = page.getByTestId('position-f5')
475
+
476
+ // Start dragging the piece (pointerdown)
477
+ await whitePiecePosition
478
+ .element()
479
+ .dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
480
+ await nextTick()
481
+
482
+ // Verify dragging started - draggable piece SVG should be visible
483
+ await expect.element(page.getByTestId('drag-piece')).toBeVisible()
484
+
485
+ // Move pointer off the board (simulate pointermove on window)
486
+ window.dispatchEvent(
487
+ new PointerEvent('pointermove', { clientX: 0, clientY: 0, bubbles: true }),
488
+ )
489
+ await nextTick()
490
+
491
+ // Release pointer (pointerup on window) - this should reset dragging state
492
+ window.dispatchEvent(new PointerEvent('pointerup', { bubbles: true }))
493
+ await nextTick()
494
+
495
+ // Verify piece is still selected (autoselect should have set it on mousedown)
496
+ await expect.element(page.getByTestId('selected-f5')).toBeVisible()
497
+ await expect
498
+ .element(page.getByTestId('assertion'))
499
+ .toHaveTextContent(index('f5'))
500
+
501
+ // Verify dragging state is reset - draggable piece SVG should no longer exist
502
+ await expect.element(page.getByTestId('drag-piece')).not.toBeInTheDocument()
503
+ })
504
+
505
+ test('drag and drop piece emits move event', async () => {
506
+ const selected = ref<number | null>(null)
507
+ const targets = ref<number[]>([])
508
+ const onMove = vi.fn()
509
+
510
+ setup(() => {
511
+ return () => (
512
+ <Hexboard
513
+ active
514
+ autoselect
515
+ playing="w"
516
+ v-model:selected={selected.value}
517
+ v-model:targets={targets.value}
518
+ onMove={onMove}
519
+ />
520
+ )
521
+ })
522
+
523
+ const fromPosition = page.getByTestId('position-f5')
524
+ const toPosition = page.getByTestId('position-f6')
525
+
526
+ // Start dragging the piece (pointerdown)
527
+ await fromPosition
528
+ .element()
529
+ .dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
530
+ await nextTick()
531
+
532
+ // Verify dragging started
533
+ await expect.element(page.getByTestId('drag-piece')).toBeVisible()
534
+
535
+ // Move pointer to target position and release (pointerup)
536
+ await toPosition
537
+ .element()
538
+ .dispatchEvent(new PointerEvent('pointerup', { bubbles: true }))
539
+ await nextTick()
540
+
541
+ // Verify move event was emitted with correct San object
542
+ await expect(onMove).toHaveBeenCalledOnce()
543
+ await expect(onMove).toHaveBeenCalledWith(expect.any(San))
544
+ const san = onMove.mock.calls[0][0] as San
545
+ await expect(san.from).toBe(index('f5'))
546
+ await expect(san.to).toBe(index('f6'))
547
+ })
548
+
549
+ test('click to move piece emits move event', async () => {
550
+ const selected = ref<number | null>(null)
551
+ const targets = ref<number[]>([])
552
+ const onMove = vi.fn()
553
+
554
+ setup(() => {
555
+ return () => (
556
+ <Hexboard
557
+ active
558
+ autoselect
559
+ playing="w"
560
+ v-model:selected={selected.value}
561
+ v-model:targets={targets.value}
562
+ onMove={onMove}
563
+ />
564
+ )
565
+ })
566
+
567
+ // Click on a piece to select it (f5 should have a white piece)
568
+ await page.getByTestId('position-f5').click()
569
+ await nextTick()
570
+
571
+ // Verify piece is selected and targets are shown
572
+ await expect.element(page.getByTestId('selected-f5')).toBeVisible()
573
+ await expect.element(page.getByTestId('target-f6')).toBeVisible()
574
+
575
+ // Click on a target position to move
576
+ await page.getByTestId('position-f6').click()
577
+ await nextTick()
578
+
579
+ // Verify move event was emitted with correct San object
580
+ await expect(onMove).toHaveBeenCalledOnce()
581
+ await expect(onMove).toHaveBeenCalledWith(expect.any(San))
582
+ const san = onMove.mock.calls[0][0] as San
583
+ await expect(san.from).toBe(index('f5'))
584
+ await expect(san.to).toBe(index('f6'))
585
+ })
586
+
587
+ test('cannot move piece of other turn color', async () => {
588
+ const selected = ref<number | null>(null)
589
+ const targets = ref<number[]>([])
590
+ const onMove = vi.fn()
591
+
592
+ setup(() => {
593
+ return () => (
594
+ <Hexboard
595
+ active
596
+ autoselect
597
+ playing={true}
598
+ v-model:selected={selected.value}
599
+ v-model:targets={targets.value}
600
+ onMove={onMove}
601
+ />
602
+ )
603
+ })
604
+
605
+ // Initial position is white's turn, so try to move a black piece
606
+ // Click on a black piece (b7 should have a black rook in initial position)
607
+ await page.getByTestId('position-b7').click()
608
+ await nextTick()
609
+
610
+ // Verify piece is selected and targets are shown
611
+ await expect.element(page.getByTestId('selected-b7')).toBeVisible()
612
+ await expect.element(page.getByTestId('target-b6')).toBeVisible()
613
+
614
+ // Try to click on a target position to move
615
+ await page.getByTestId('position-b6').click()
616
+ await nextTick()
617
+
618
+ // Verify move event was NOT called (can't move black piece on white's turn)
619
+ await expect(onMove).not.toHaveBeenCalled()
620
+ })
621
+
622
+ test('promotion', async () => {
623
+ setup(() => {
624
+ const hexchess = ref(
625
+ Hexchess.parse('1/1P1/5/7/9/11/11/11/11/11/11 w - 0 1'),
626
+ )
627
+
628
+ return () => (
629
+ <Hexboard
630
+ active
631
+ autoselect
632
+ playing="w"
633
+ hexchess={hexchess.value}
634
+ onMove={san => hexchess.value.applyMoveUnsafe(san)}
635
+ >
636
+ {{
637
+ promotion: ({ promote }: any) => (
638
+ <button data-testid="promote" onClick={() => promote('q')}>
639
+ q
640
+ </button>
641
+ ),
642
+ }}
643
+ </Hexboard>
644
+ )
645
+ })
646
+
647
+ await makeMove(page, 'f10f11')
648
+ await page.getByTestId('promote').click()
649
+ await expect(page.getByTestId('piece-f11')).toHaveAttribute(
650
+ 'data-piece-type',
651
+ 'Q',
652
+ )
653
+ })
654
+
655
+ test('canceled promotion', async () => {
656
+ const selected = ref<number | null>(null)
657
+ const onMove = vi.fn()
658
+
659
+ setup(() => {
660
+ const hexchess = ref(
661
+ Hexchess.parse('1/1P1/5/7/9/11/11/11/11/11/11 w - 0 1'),
662
+ )
663
+
664
+ return () => (
665
+ <Hexboard
666
+ active
667
+ autoselect
668
+ playing="w"
669
+ hexchess={hexchess.value}
670
+ v-model:selected={selected.value}
671
+ onMove={onMove}
672
+ >
673
+ {{
674
+ promotion: ({ cancel }: any) => (
675
+ <button data-testid="cancel" onClick={cancel}>
676
+ cancel
677
+ </button>
678
+ ),
679
+ }}
680
+ </Hexboard>
681
+ )
682
+ })
683
+
684
+ // Move pawn to promotion square
685
+ await makeMove(page, 'f10f11')
686
+
687
+ // Promotion UI should be visible
688
+ await expect.element(page.getByTestId('cancel')).toBeVisible()
689
+
690
+ // Cancel the promotion
691
+ await page.getByTestId('cancel').click()
692
+ await nextTick()
693
+
694
+ // Move should not have been emitted
695
+ await expect(onMove).not.toHaveBeenCalled()
696
+
697
+ // Original piece should still be selected
698
+ await expect(selected.value).toBe(index('f10'))
699
+ await expect.element(page.getByTestId('selected-f10')).toBeVisible()
700
+
701
+ // Pawn should still be at original position
702
+ await expect
703
+ .element(page.getByTestId('piece-f10'))
704
+ .toHaveAttribute('data-piece-type', 'P')
705
+ })
706
+
707
+ test('clicking position during promotion cancels it', async () => {
708
+ const selected = ref<number | null>(null)
709
+ const onMove = vi.fn()
710
+
711
+ setup(() => {
712
+ const hexchess = ref(
713
+ Hexchess.parse('1/1P1/5/7/9/11/11/11/11/11/11 w - 0 1'),
714
+ )
715
+
716
+ return () => (
717
+ <Hexboard
718
+ active
719
+ autoselect
720
+ playing="w"
721
+ hexchess={hexchess.value}
722
+ v-model:selected={selected.value}
723
+ onMove={onMove}
724
+ >
725
+ {{
726
+ promotion: () => <button data-testid="promote">promote</button>,
727
+ }}
728
+ </Hexboard>
729
+ )
730
+ })
731
+
732
+ // Move pawn to promotion square
733
+ await makeMove(page, 'f10f11')
734
+
735
+ // Promotion UI should be visible
736
+ await expect.element(page.getByTestId('promote')).toBeVisible()
737
+
738
+ // Click on a different position to cancel the promotion
739
+ await page.getByTestId('position-a1').click()
740
+ await nextTick()
741
+
742
+ // Promotion UI should be gone
743
+ await expect.element(page.getByTestId('promote')).not.toBeInTheDocument()
744
+
745
+ // Move should not have been emitted
746
+ await expect(onMove).not.toHaveBeenCalled()
747
+
748
+ // Original piece should still be selected
749
+ await expect.element(page.getByTestId('selected-f10')).toBeVisible()
750
+
751
+ // Pawn should still be at original position
752
+ await expect
753
+ .element(page.getByTestId('piece-f10'))
754
+ .toHaveAttribute('data-piece-type', 'P')
755
+ })
756
+
757
+ test('ignoreTurn allows moving pieces out of turn', async () => {
758
+ const ignoreTurn = ref(false)
759
+ const onMove = vi.fn()
760
+
761
+ setup(() => {
762
+ return () => (
763
+ <Hexboard
764
+ active
765
+ autoselect
766
+ ignoreTurn={ignoreTurn.value}
767
+ playing={true}
768
+ onMove={onMove}
769
+ />
770
+ )
771
+ })
772
+
773
+ // Initial position is white's turn, try to move black's pawn without ignoreTurn
774
+ await makeMove(page, 'f7f6')
775
+ await expect(onMove).not.toHaveBeenCalled()
776
+
777
+ // Enable ignoreTurn and try again
778
+ ignoreTurn.value = true
779
+ await nextTick()
780
+
781
+ await makeMove(page, 'f7f6')
782
+ await expect(onMove).toHaveBeenCalledOnce()
783
+ await expect(onMove).toHaveBeenCalledWith(
784
+ expect.objectContaining({
785
+ from: index('f7'),
786
+ to: index('f6'),
787
+ }),
788
+ )
789
+ })
790
+
791
+ test('dragging piece to non-target position keeps selection', async () => {
792
+ const selected = ref<number | null>(null)
793
+ const targets = ref<number[]>([])
794
+
795
+ setup(() => {
796
+ return () => (
797
+ <>
798
+ <Hexboard
799
+ active
800
+ autoselect
801
+ playing="w"
802
+ v-model:selected={selected.value}
803
+ v-model:targets={targets.value}
804
+ />
805
+ <div data-testid="selected-value" v-text={selected.value} />
806
+ </>
807
+ )
808
+ })
809
+
810
+ const piecePosition = page.getByTestId('position-f5')
811
+ const nonTargetPosition = page.getByTestId('position-a1')
812
+
813
+ // Start dragging the piece
814
+ await piecePosition
815
+ .element()
816
+ .dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
817
+ await nextTick()
818
+
819
+ // Verify piece is selected
820
+ await expect.element(page.getByTestId('selected-f5')).toBeVisible()
821
+ await expect(selected.value).toBe(index('f5'))
822
+
823
+ // Release on a non-target position
824
+ await nonTargetPosition
825
+ .element()
826
+ .dispatchEvent(new PointerEvent('pointerup', { bubbles: true }))
827
+ await nextTick()
828
+
829
+ // Piece should still be selected
830
+ await expect.element(page.getByTestId('selected-f5')).toBeVisible()
831
+ await expect(selected.value).toBe(index('f5'))
832
+ })