@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.
- package/.github/workflows/build.yml +65 -0
- package/.github/workflows/release.yml +0 -0
- package/.vscode/settings.json +5 -0
- package/LICENSE +21 -0
- package/README.md +8 -0
- package/dist/index.d.ts +127 -0
- package/dist/index.js +1056 -0
- package/eslint.config.js +85 -0
- package/index.html +22 -0
- package/package.json +58 -0
- package/src/lib/components/hexboard/Hexboard.vue +865 -0
- package/src/lib/components/hexboard/constants.ts +389 -0
- package/src/lib/components/hexboard/dom.ts +19 -0
- package/src/lib/components/hexboard/geometry.ts +59 -0
- package/src/lib/components/hexboard/haptics.ts +56 -0
- package/src/lib/components/hexboard/pieces/Celtic.vue +22 -0
- package/src/lib/components/hexboard/pieces/Fantasy.vue +22 -0
- package/src/lib/components/hexboard/pieces/Gioco.vue +22 -0
- package/src/lib/components/hexboard/pieces/Spatial.vue +22 -0
- package/src/lib/components/hexboard/pieces/index.ts +4 -0
- package/src/lib/components/hexboard/types.ts +28 -0
- package/src/lib/index.ts +1 -0
- package/src/sandbox/App.vue +28 -0
- package/src/sandbox/components/Button.vue +8 -0
- package/src/sandbox/components/icons/Github.vue +3 -0
- package/src/sandbox/components/icons/Menu.vue +3 -0
- package/src/sandbox/components/icons/X.vue +3 -0
- package/src/sandbox/index.ts +5 -0
- package/src/sandbox/tailwind.css +59 -0
- package/src/sandbox/views/HomeToolbar.vue +80 -0
- package/src/tests/example.test.tsx +18 -0
- package/src/tests/hexboard.test.tsx +832 -0
- package/src/tests/utils.ts +26 -0
- package/tsconfig.json +30 -0
- package/tsconfig.node.json +10 -0
- package/vite.config.ts +42 -0
- package/vite.sandbox.config.ts +21 -0
- 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
|
+
})
|