cm-chessboard 8.12.17 → 8.12.19
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/README.md +24 -3
- package/package.json +3 -2
- package/src/Chessboard.js +8 -0
- package/src/view/ChessboardView.js +2 -7
- package/src/view/VisualMoveInput.js +53 -9
- package/test/TestArrows.js +33 -0
- package/test/TestChessboard.js +44 -1
- package/test/TestMarkers.js +15 -0
- package/test/TestPosition.js +36 -0
- package/test/TestVisualMoveInput.js +295 -11
- package/test/headless.mjs +109 -0
- package/test/index.html +2 -0
package/README.md
CHANGED
|
@@ -207,9 +207,13 @@ The event has the following **`event.type`**:
|
|
|
207
207
|
Return `true` or `false` to validate the start square. `false` cancels the move.
|
|
208
208
|
- **`INPUT_EVENT_TYPE.validateMoveInput`**: To validate the users move input. `event.squareFrom` and `event.squareTo`
|
|
209
209
|
contain the coordinates. Return `true` or `false` to validate the move. `false` cancels the move.
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
210
|
+
The validator runs once at most per sequence of actions that describe a move (e.g. click starting piece, click destination),
|
|
211
|
+
so side effects like updating game position state or triggering sounds can run here.
|
|
212
|
+
One special case is moving a piece to a square occupied by another friendly piece. This kind of move can be useful
|
|
213
|
+
when you want to allow chess960 castling style (moving the king into the rook position) or when you want to allow
|
|
214
|
+
recapture premoves. Return `true` to commit it (e.g. castling by clicking the rook). When you return `false` here the
|
|
215
|
+
clicked own piece simply becomes the new selection, so if you show illegal-move feedback, skip it when `event.squareTo`
|
|
216
|
+
still holds one of the mover's own pieces.
|
|
213
217
|
- **`INPUT_EVENT_TYPE.moveInputCanceled`**: The user canceled the move with clicking again on the start square, clicking
|
|
214
218
|
outside the board or right click.
|
|
215
219
|
- **`INPUT_EVENT_TYPE.moveInputFinished`**: Fired after the move was made, also when canceled.
|
|
@@ -243,6 +247,12 @@ chessboard.enableMoveInput((event) => {
|
|
|
243
247
|
|
|
244
248
|
Disables moves via user input.
|
|
245
249
|
|
|
250
|
+
### cancelMoveInput()
|
|
251
|
+
|
|
252
|
+
Cancels a move input that is currently in progress (a piece picked up or a square selected), without disabling move
|
|
253
|
+
input. Fires a `moveInputCanceled` event with `event.reason === "canceled"`. It is a no-op when no move input is in
|
|
254
|
+
progress. Useful to abort a move from your own UI, e.g. on an "escape" key press or a cancel button.
|
|
255
|
+
|
|
246
256
|
### isMoveInputEnabled()
|
|
247
257
|
|
|
248
258
|
Returns `true` if move input is currently enabled for white or black.
|
|
@@ -419,6 +429,17 @@ const chessboard = new Chessboard(document.getElementById("board"), {
|
|
|
419
429
|
|
|
420
430
|
When `movePieceForm` is enabled, press **Shift+Option+E** (Mac) or **Shift+Alt+E** (Windows/Linux) to focus the "Move from" input field.
|
|
421
431
|
|
|
432
|
+
## Testing
|
|
433
|
+
|
|
434
|
+
The unit tests use the tiny [Teevi](https://github.com/shaack/teevi) framework and run in a real browser.
|
|
435
|
+
|
|
436
|
+
- **In the browser (no install needed):** open [`test/index.html`](test/index.html) directly, or run `npm test`, which
|
|
437
|
+
just prints that hint.
|
|
438
|
+
- **Headless (optional):** `npm run test:headless` runs the same suite in headless Chrome and exits non-zero on failure,
|
|
439
|
+
which is handy for CI. To keep cm-chessboard dependency-free, [puppeteer](https://pptr.dev) is **not** a project
|
|
440
|
+
dependency; install it **globally** for this: `npm install -g puppeteer`. The runner starts its own static server, so
|
|
441
|
+
nothing else is required.
|
|
442
|
+
|
|
422
443
|
## Usage with JS Frameworks
|
|
423
444
|
|
|
424
445
|
- Works with **Vue** out of the box
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cm-chessboard",
|
|
3
|
-
"version": "8.12.
|
|
3
|
+
"version": "8.12.19",
|
|
4
4
|
"description": "A JavaScript chessboard which is lightweight, ES6 module based, responsive, SVG rendered and without dependencies.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"chess",
|
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
"module": "./src/Chessboard.js",
|
|
17
17
|
"browser": "./src/Chessboard.js",
|
|
18
18
|
"scripts": {
|
|
19
|
-
"test": "tput setaf 4;echo 'Run test/index.html in your browser for unit testing.'; echo ''; exit 0"
|
|
19
|
+
"test": "tput setaf 4;echo 'Run test/index.html in your browser for unit testing, or `npm run test:headless` (needs a global puppeteer).'; echo ''; exit 0",
|
|
20
|
+
"test:headless": "node test/headless.mjs"
|
|
20
21
|
},
|
|
21
22
|
"repository": {
|
|
22
23
|
"type": "git",
|
package/src/Chessboard.js
CHANGED
|
@@ -77,6 +77,7 @@ export class Chessboard {
|
|
|
77
77
|
async setPiece(square, piece, animated = false) {
|
|
78
78
|
const positionFrom = this.state.position.clone()
|
|
79
79
|
this.state.position.setPiece(square, piece)
|
|
80
|
+
this.view.visualMoveInput.positionChanged()
|
|
80
81
|
this.state.invokeExtensionPoints(EXTENSION_POINT.positionChanged)
|
|
81
82
|
return this.positionAnimationsQueue.enqueuePositionChange(positionFrom, this.state.position.clone(), animated)
|
|
82
83
|
}
|
|
@@ -84,6 +85,7 @@ export class Chessboard {
|
|
|
84
85
|
async movePiece(squareFrom, squareTo, animated = false) {
|
|
85
86
|
const positionFrom = this.state.position.clone()
|
|
86
87
|
this.state.position.movePiece(squareFrom, squareTo)
|
|
88
|
+
this.view.visualMoveInput.positionChanged()
|
|
87
89
|
this.state.invokeExtensionPoints(EXTENSION_POINT.positionChanged)
|
|
88
90
|
return this.positionAnimationsQueue.enqueuePositionChange(positionFrom, this.state.position.clone(), animated)
|
|
89
91
|
}
|
|
@@ -93,6 +95,7 @@ export class Chessboard {
|
|
|
93
95
|
const positionTo = new Position(fen)
|
|
94
96
|
if (positionFrom.getFen() !== positionTo.getFen()) {
|
|
95
97
|
this.state.position.setFen(fen)
|
|
98
|
+
this.view.visualMoveInput.positionChanged()
|
|
96
99
|
this.state.invokeExtensionPoints(EXTENSION_POINT.positionChanged)
|
|
97
100
|
}
|
|
98
101
|
return this.positionAnimationsQueue.enqueuePositionChange(positionFrom, this.state.position.clone(), animated)
|
|
@@ -131,6 +134,11 @@ export class Chessboard {
|
|
|
131
134
|
this.view.disableMoveInput()
|
|
132
135
|
}
|
|
133
136
|
|
|
137
|
+
// Cancel a move input that is currently in progress (leaves move input enabled).
|
|
138
|
+
cancelMoveInput() {
|
|
139
|
+
this.view.visualMoveInput.cancelMoveInput()
|
|
140
|
+
}
|
|
141
|
+
|
|
134
142
|
isMoveInputEnabled() {
|
|
135
143
|
return this.state.inputWhiteEnabled || this.state.inputBlackEnabled
|
|
136
144
|
}
|
|
@@ -410,18 +410,13 @@ export class ChessboardView {
|
|
|
410
410
|
this.chessboard.state.invokeExtensionPoints(EXTENSION_POINT.moveInput, data)
|
|
411
411
|
}
|
|
412
412
|
|
|
413
|
-
validateMoveInputCallback(squareFrom, squareTo
|
|
413
|
+
validateMoveInputCallback(squareFrom, squareTo) {
|
|
414
414
|
const data = {
|
|
415
415
|
chessboard: this.chessboard,
|
|
416
416
|
type: INPUT_EVENT_TYPE.validateMoveInput,
|
|
417
417
|
squareFrom: squareFrom,
|
|
418
418
|
squareTo: squareTo,
|
|
419
|
-
piece: this.chessboard.getPiece(squareFrom)
|
|
420
|
-
// true when this is only a speculative check whether the clicked
|
|
421
|
-
// same-color piece is a legal target (e.g. chess960 castling). A
|
|
422
|
-
// falsy result then means "re-select this piece", not an illegal
|
|
423
|
-
// move, so consumers can suppress illegal-move feedback.
|
|
424
|
-
probe: probe
|
|
419
|
+
piece: this.chessboard.getPiece(squareFrom)
|
|
425
420
|
}
|
|
426
421
|
if (this.chessboard.state.moveInputCallback) {
|
|
427
422
|
data.moveInputCallbackResult = this.chessboard.state.moveInputCallback(data)
|
|
@@ -24,7 +24,9 @@ export const MOVE_CANCELED_REASON = {
|
|
|
24
24
|
movedOutOfBoard: "movedOutOfBoard",
|
|
25
25
|
draggedBack: "draggedBack", // dragged to the start square
|
|
26
26
|
clickedAnotherPiece: "clickedAnotherPiece", // of the same color
|
|
27
|
-
touchCanceled: "touchCanceled"
|
|
27
|
+
touchCanceled: "touchCanceled",
|
|
28
|
+
movedPieceChanged: "movedPieceChanged", // the held piece changed on the board, most likely got captured
|
|
29
|
+
canceled: "canceled" // cancelled programmatically via chessboard.cancelMoveInput()
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
const DRAG_THRESHOLD = 4
|
|
@@ -37,6 +39,7 @@ export class VisualMoveInput {
|
|
|
37
39
|
this.moveInputState = null
|
|
38
40
|
this.fromSquare = null
|
|
39
41
|
this.toSquare = null
|
|
42
|
+
this.movedPiece = null
|
|
40
43
|
|
|
41
44
|
this.setMoveInputState(MOVE_INPUT_STATE.waitForInputStart)
|
|
42
45
|
}
|
|
@@ -59,8 +62,8 @@ export class VisualMoveInput {
|
|
|
59
62
|
this.view.movingOverSquareCallback(fromSquare, toSquare)
|
|
60
63
|
}
|
|
61
64
|
|
|
62
|
-
validateMoveInputCallback(fromSquare, toSquare
|
|
63
|
-
const result = this.view.validateMoveInputCallback(fromSquare, toSquare
|
|
65
|
+
validateMoveInputCallback(fromSquare, toSquare) {
|
|
66
|
+
const result = this.view.validateMoveInputCallback(fromSquare, toSquare)
|
|
64
67
|
this.chessboard.state.moveInputProcess.resolve(result)
|
|
65
68
|
return result
|
|
66
69
|
}
|
|
@@ -171,7 +174,10 @@ export class VisualMoveInput {
|
|
|
171
174
|
throw new Error("moveInputState")
|
|
172
175
|
}
|
|
173
176
|
this.toSquare = params.square
|
|
174
|
-
if
|
|
177
|
+
// if the move was already validated, don't trigger the validator again so possible user side effects run once at most
|
|
178
|
+
const validated = params.validated !== undefined ?
|
|
179
|
+
params.validated : this.validateMoveInputCallback(this.fromSquare, this.toSquare)
|
|
180
|
+
if (this.toSquare && validated) {
|
|
175
181
|
this.chessboard.movePiece(this.fromSquare, this.toSquare, prevState === MOVE_INPUT_STATE.clickTo).then(() => {
|
|
176
182
|
if (prevState === MOVE_INPUT_STATE.clickTo) {
|
|
177
183
|
this.view.setPieceVisibility(this.toSquare, true)
|
|
@@ -298,11 +304,11 @@ export class VisualMoveInput {
|
|
|
298
304
|
const startPieceName = this.chessboard.getPiece(this.fromSquare)
|
|
299
305
|
const startPieceColor = startPieceName ? startPieceName.substring(0, 1) : null
|
|
300
306
|
if (color && startPieceColor === pieceColor) {
|
|
301
|
-
// added to allow chess960
|
|
302
|
-
//
|
|
303
|
-
//
|
|
304
|
-
const result = this.validateMoveInputCallback(this.fromSquare, square
|
|
305
|
-
if(!result) {
|
|
307
|
+
// added to allow moves into own pieces, useful for chess960 castle style or recapture premoves
|
|
308
|
+
// result holds false if the user legality checker deemed the move into another own piece as illegal
|
|
309
|
+
// in that case, we start a new move by selecting the target piece
|
|
310
|
+
const result = this.validateMoveInputCallback(this.fromSquare, square)
|
|
311
|
+
if (!result) {
|
|
306
312
|
this.moveInputCanceledCallback(this.fromSquare, square, MOVE_CANCELED_REASON.clickedAnotherPiece)
|
|
307
313
|
if (this.moveInputStartedCallback(square)) {
|
|
308
314
|
this.setMoveInputState(MOVE_INPUT_STATE.pieceClickedThreshold, {
|
|
@@ -314,6 +320,10 @@ export class VisualMoveInput {
|
|
|
314
320
|
} else {
|
|
315
321
|
this.setMoveInputState(MOVE_INPUT_STATE.reset)
|
|
316
322
|
}
|
|
323
|
+
} else {
|
|
324
|
+
// if the user deemed the move into own piece legal, execute it
|
|
325
|
+
// but prevent validating the move again with the validated flag
|
|
326
|
+
this.setMoveInputState(MOVE_INPUT_STATE.moveDone, {square: square, validated: true})
|
|
317
327
|
}
|
|
318
328
|
} else {
|
|
319
329
|
this.setMoveInputState(MOVE_INPUT_STATE.moveDone, {square: square})
|
|
@@ -425,6 +435,40 @@ export class VisualMoveInput {
|
|
|
425
435
|
this.moveInputCanceledCallback(this.fromSquare, null, MOVE_CANCELED_REASON.secondaryClick)
|
|
426
436
|
}
|
|
427
437
|
|
|
438
|
+
// Cancel a move input that is currently in progress. No-op when idle.
|
|
439
|
+
cancelMoveInput() {
|
|
440
|
+
if (this.moveInputState !== MOVE_INPUT_STATE.waitForInputStart) {
|
|
441
|
+
const moveStartSquare = this.fromSquare
|
|
442
|
+
this.view.redrawPieces()
|
|
443
|
+
this.setMoveInputState(MOVE_INPUT_STATE.reset)
|
|
444
|
+
this.moveInputCanceledCallback(moveStartSquare, null, MOVE_CANCELED_REASON.canceled)
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Called after the board position changed (setPosition/movePiece/setPiece).
|
|
449
|
+
// If the piece we are holding changed on its square — most likely captured
|
|
450
|
+
// by an external position update — cancel the in-progress move input.
|
|
451
|
+
positionChanged() {
|
|
452
|
+
// Only cancel while actually holding a piece. Never in moveDone/reset:
|
|
453
|
+
// completing a move itself goes through movePiece() and must not self-cancel.
|
|
454
|
+
const holdingStates = [
|
|
455
|
+
MOVE_INPUT_STATE.pieceClickedThreshold,
|
|
456
|
+
MOVE_INPUT_STATE.clickTo,
|
|
457
|
+
MOVE_INPUT_STATE.secondClickThreshold,
|
|
458
|
+
MOVE_INPUT_STATE.dragTo,
|
|
459
|
+
MOVE_INPUT_STATE.clickDragTo
|
|
460
|
+
]
|
|
461
|
+
if (this.fromSquare && holdingStates.includes(this.moveInputState) &&
|
|
462
|
+
this.chessboard.getPiece(this.fromSquare) !== this.movedPiece) {
|
|
463
|
+
const moveStartSquare = this.fromSquare
|
|
464
|
+
// drop the stale held piece so the reset branch does not stamp it
|
|
465
|
+
// back onto the square, overwriting the piece just placed there
|
|
466
|
+
this.movedPiece = null
|
|
467
|
+
this.setMoveInputState(MOVE_INPUT_STATE.reset)
|
|
468
|
+
this.moveInputCanceledCallback(moveStartSquare, null, MOVE_CANCELED_REASON.movedPieceChanged)
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
428
472
|
isDragging() {
|
|
429
473
|
return this.moveInputState === MOVE_INPUT_STATE.dragTo || this.moveInputState === MOVE_INPUT_STATE.clickDragTo
|
|
430
474
|
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Author and copyright: Stefan Haack (https://shaack.com)
|
|
3
|
+
* Repository: https://github.com/shaack/cm-chessboard
|
|
4
|
+
* License: MIT, see file 'LICENSE'
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import {describe, it, assert} from "../node_modules/teevi/src/teevi.js"
|
|
8
|
+
import {Chessboard} from "../src/Chessboard.js"
|
|
9
|
+
import {ARROW_TYPE, Arrows} from "../src/extensions/arrows/Arrows.js"
|
|
10
|
+
|
|
11
|
+
describe("TestArrows", () => {
|
|
12
|
+
|
|
13
|
+
it("should add, get and remove arrows", () => {
|
|
14
|
+
const chessboard = new Chessboard(document.getElementById("TestArrows"), {
|
|
15
|
+
assetsUrl: "../assets/",
|
|
16
|
+
extensions: [{class: Arrows}]
|
|
17
|
+
})
|
|
18
|
+
chessboard.addArrow(ARROW_TYPE.default, "e2", "e4")
|
|
19
|
+
chessboard.addArrow(ARROW_TYPE.danger, "d2", "d4")
|
|
20
|
+
assert.equal(chessboard.getArrows().length, 2)
|
|
21
|
+
assert.equal(chessboard.getArrows(ARROW_TYPE.danger).length, 1)
|
|
22
|
+
assert.equal(chessboard.getArrows(undefined, "e2").length, 1)
|
|
23
|
+
assert.equal(chessboard.getArrows(undefined, "e2", "e4").length, 1)
|
|
24
|
+
assert.equal(chessboard.getArrows(undefined, "a1").length, 0)
|
|
25
|
+
chessboard.removeArrows(undefined, "e2")
|
|
26
|
+
assert.equal(chessboard.getArrows().length, 1)
|
|
27
|
+
assert.equal(chessboard.getArrows()[0].from, "d2")
|
|
28
|
+
chessboard.removeArrows()
|
|
29
|
+
assert.equal(chessboard.getArrows().length, 0)
|
|
30
|
+
chessboard.destroy()
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
})
|
package/test/TestChessboard.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import {describe, it, assert} from "../node_modules/teevi/src/teevi.js"
|
|
8
|
-
import {PIECE, Chessboard} from "../src/Chessboard.js"
|
|
8
|
+
import {PIECE, COLOR, Chessboard} from "../src/Chessboard.js"
|
|
9
9
|
import {FEN} from "../src/model/Position.js"
|
|
10
10
|
|
|
11
11
|
describe("TestChessboard", () => {
|
|
@@ -69,6 +69,49 @@ describe("TestChessboard", () => {
|
|
|
69
69
|
chessboard.destroy()
|
|
70
70
|
})
|
|
71
71
|
|
|
72
|
+
it("should move a piece via the api", async () => {
|
|
73
|
+
const chessboard = new Chessboard(document.getElementById("TestBoard"), {
|
|
74
|
+
assetsUrl: "../assets/",
|
|
75
|
+
position: FEN.start,
|
|
76
|
+
style: {animationDuration: 0}
|
|
77
|
+
})
|
|
78
|
+
await chessboard.movePiece("e2", "e4", false)
|
|
79
|
+
assert.equal(chessboard.getPiece("e2"), null)
|
|
80
|
+
assert.equal(chessboard.getPiece("e4"), "wp")
|
|
81
|
+
chessboard.destroy()
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it("should remove a piece when set to null", () => {
|
|
85
|
+
const chessboard = new Chessboard(document.getElementById("TestBoard"), {
|
|
86
|
+
assetsUrl: "../assets/",
|
|
87
|
+
position: FEN.start
|
|
88
|
+
})
|
|
89
|
+
chessboard.setPiece("e2", null)
|
|
90
|
+
assert.equal(chessboard.getPiece("e2"), null)
|
|
91
|
+
chessboard.destroy()
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it("should create an empty board from FEN.empty", () => {
|
|
95
|
+
const chessboard = new Chessboard(document.getElementById("TestBoard"), {
|
|
96
|
+
assetsUrl: "../assets/",
|
|
97
|
+
position: FEN.empty
|
|
98
|
+
})
|
|
99
|
+
assert.equal("" + chessboard.getPosition(), "8/8/8/8/8/8/8/8")
|
|
100
|
+
chessboard.destroy()
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it("should set and get the orientation", async () => {
|
|
104
|
+
const chessboard = new Chessboard(document.getElementById("TestBoard"), {
|
|
105
|
+
assetsUrl: "../assets/",
|
|
106
|
+
position: FEN.start,
|
|
107
|
+
style: {animationDuration: 0}
|
|
108
|
+
})
|
|
109
|
+
assert.equal(chessboard.getOrientation(), COLOR.white)
|
|
110
|
+
await chessboard.setOrientation(COLOR.black)
|
|
111
|
+
assert.equal(chessboard.getOrientation(), COLOR.black)
|
|
112
|
+
chessboard.destroy()
|
|
113
|
+
})
|
|
114
|
+
|
|
72
115
|
// Regression for https://github.com/shaack/cm-chessboard/issues/154
|
|
73
116
|
//
|
|
74
117
|
// A setPosition() call with a null diff (positionFrom === positionTo)
|
package/test/TestMarkers.js
CHANGED
|
@@ -39,4 +39,19 @@ describe("TestMarkers", () => {
|
|
|
39
39
|
chessboard.destroy()
|
|
40
40
|
})
|
|
41
41
|
|
|
42
|
+
it("should add different marker types and remove all markers at once", () => {
|
|
43
|
+
const chessboard = new Chessboard(document.getElementById("TestMarkers"), {
|
|
44
|
+
assetsUrl: "../assets/",
|
|
45
|
+
extensions: [{class: Markers}]
|
|
46
|
+
})
|
|
47
|
+
chessboard.addMarker(MARKER_TYPE.dot, "e4")
|
|
48
|
+
chessboard.addMarker(MARKER_TYPE.circle, "d4")
|
|
49
|
+
chessboard.addMarker(MARKER_TYPE.circleDanger, "c4")
|
|
50
|
+
assert.equal(chessboard.getMarkers().length, 3)
|
|
51
|
+
assert.equal(chessboard.getMarkers(MARKER_TYPE.dot).length, 1)
|
|
52
|
+
chessboard.removeMarkers()
|
|
53
|
+
assert.equal(chessboard.getMarkers().length, 0)
|
|
54
|
+
chessboard.destroy()
|
|
55
|
+
})
|
|
56
|
+
|
|
42
57
|
})
|
package/test/TestPosition.js
CHANGED
|
@@ -39,5 +39,41 @@ describe("TestPosition", () => {
|
|
|
39
39
|
assert.equal(rooks.length, 1)
|
|
40
40
|
assert.equal(rooks[0].square, "f5")
|
|
41
41
|
})
|
|
42
|
+
it("should round-trip every square through index conversion", () => {
|
|
43
|
+
for (let i = 0; i < 64; i++) {
|
|
44
|
+
assert.equal(Position.squareToIndex(Position.indexToSquare(i)), i)
|
|
45
|
+
}
|
|
46
|
+
})
|
|
47
|
+
it("should set, get and remove a piece", () => {
|
|
48
|
+
const position = new Position(FEN.empty)
|
|
49
|
+
assert.equal(position.getPiece("e4"), null)
|
|
50
|
+
position.setPiece("e4", "wq")
|
|
51
|
+
assert.equal(position.getPiece("e4"), "wq")
|
|
52
|
+
position.setPiece("e4", null)
|
|
53
|
+
assert.equal(position.getPiece("e4"), null)
|
|
54
|
+
})
|
|
55
|
+
it("should move a piece", () => {
|
|
56
|
+
const position = new Position("8/8/8/8/8/8/4P3/8")
|
|
57
|
+
position.movePiece("e2", "e4")
|
|
58
|
+
assert.equal(position.getPiece("e2"), null)
|
|
59
|
+
assert.equal(position.getPiece("e4"), "wp")
|
|
60
|
+
})
|
|
61
|
+
it("should represent an empty board as FEN", () => {
|
|
62
|
+
const position = new Position(FEN.empty)
|
|
63
|
+
assert.equal(position.getFen(), "8/8/8/8/8/8/8/8")
|
|
64
|
+
})
|
|
65
|
+
it("should store only the piece placement of a full FEN", () => {
|
|
66
|
+
const position = new Position("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1")
|
|
67
|
+
assert.equal(position.getFen(), "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR")
|
|
68
|
+
})
|
|
69
|
+
it("should count the pieces of the start position", () => {
|
|
70
|
+
const position = new Position(FEN.start)
|
|
71
|
+
assert.equal(position.getPieces().length, 32)
|
|
72
|
+
assert.equal(position.getPieces(COLOR.white).length, 16)
|
|
73
|
+
assert.equal(position.getPieces(COLOR.black, PIECE_TYPE.pawn).length, 8)
|
|
74
|
+
})
|
|
75
|
+
it("should return no pieces for an empty board", () => {
|
|
76
|
+
assert.equal(new Position(FEN.empty).getPieces().length, 0)
|
|
77
|
+
})
|
|
42
78
|
})
|
|
43
79
|
|
|
@@ -7,11 +7,34 @@
|
|
|
7
7
|
import {describe, it, assert} from "../node_modules/teevi/src/teevi.js"
|
|
8
8
|
import {Chessboard} from "../src/Chessboard.js"
|
|
9
9
|
import {COLOR, INPUT_EVENT_TYPE} from "../src/view/ChessboardView.js"
|
|
10
|
+
import {MOVE_CANCELED_REASON} from "../src/view/VisualMoveInput.js"
|
|
10
11
|
import {FEN} from "../src/model/Position.js"
|
|
11
12
|
|
|
12
13
|
const STATE_WAIT_FOR_INPUT_START = "waitForInputStart"
|
|
13
14
|
const STATE_PIECE_CLICKED_THRESHOLD = "pieceClickedThreshold"
|
|
14
15
|
const STATE_CLICK_TO = "clickTo"
|
|
16
|
+
const STATE_DRAG_TO = "dragTo"
|
|
17
|
+
|
|
18
|
+
// Synthetic pointer events. The mouse path reads e.target / e.clientX directly,
|
|
19
|
+
// so it drives the real state machine without needing document.elementFromPoint.
|
|
20
|
+
const squareTarget = (name) => ({getAttribute: (n) => (n === "data-square" ? name : null)})
|
|
21
|
+
const mouseDown = (name, x = 100, y = 100) => ({
|
|
22
|
+
type: "mousedown", button: 0, target: squareTarget(name),
|
|
23
|
+
clientX: x, clientY: y, preventDefault: () => {}
|
|
24
|
+
})
|
|
25
|
+
const mouseMove = (name, x, y) => ({
|
|
26
|
+
type: "mousemove", target: squareTarget(name), clientX: x, clientY: y, pageX: x, pageY: y
|
|
27
|
+
})
|
|
28
|
+
const mouseUp = (name) => ({type: "mouseup", target: squareTarget(name)})
|
|
29
|
+
const tick = () => new Promise((resolve) => setTimeout(resolve, 30))
|
|
30
|
+
|
|
31
|
+
function newBoard() {
|
|
32
|
+
return new Chessboard(document.getElementById("TestBoard"), {
|
|
33
|
+
assetsUrl: "../assets/",
|
|
34
|
+
position: FEN.start,
|
|
35
|
+
style: {animationDuration: 0}
|
|
36
|
+
})
|
|
37
|
+
}
|
|
15
38
|
|
|
16
39
|
describe("TestVisualMoveInput", () => {
|
|
17
40
|
|
|
@@ -39,39 +62,300 @@ describe("TestVisualMoveInput", () => {
|
|
|
39
62
|
chessboard.destroy()
|
|
40
63
|
})
|
|
41
64
|
|
|
42
|
-
|
|
65
|
+
// Regression for https://github.com/shaack/cm-chessboard/pull/174
|
|
66
|
+
it("should validate once and finish the move when moving into own piece is marked as valid", async () => {
|
|
43
67
|
const chessboard = new Chessboard(document.getElementById("TestBoard"), {
|
|
44
68
|
assetsUrl: "../assets/",
|
|
45
69
|
position: FEN.start
|
|
46
70
|
})
|
|
47
|
-
let
|
|
71
|
+
let validateCallCount = 0
|
|
72
|
+
let moveInputFinishedCount = 0
|
|
48
73
|
chessboard.enableMoveInput((event) => {
|
|
49
74
|
if (event.type === INPUT_EVENT_TYPE.validateMoveInput) {
|
|
50
|
-
|
|
51
|
-
return
|
|
75
|
+
validateCallCount++
|
|
76
|
+
return true // accept the castling-by-clicking-the-rook move
|
|
77
|
+
} else if (event.type === INPUT_EVENT_TYPE.moveInputFinished) {
|
|
78
|
+
moveInputFinishedCount++
|
|
52
79
|
}
|
|
53
80
|
return true
|
|
54
81
|
}, COLOR.white)
|
|
55
82
|
const visualMoveInput = chessboard.view.visualMoveInput
|
|
56
83
|
|
|
57
|
-
// simulate an existing click-to-move selection of the white
|
|
58
|
-
|
|
59
|
-
visualMoveInput.moveInputStartedCallback("e2")
|
|
84
|
+
// simulate an existing click-to-move selection of the white king on e1
|
|
85
|
+
visualMoveInput.moveInputStartedCallback("e1")
|
|
60
86
|
visualMoveInput.moveInputState = STATE_CLICK_TO
|
|
61
|
-
visualMoveInput.fromSquare = "
|
|
87
|
+
visualMoveInput.fromSquare = "e1"
|
|
62
88
|
|
|
63
|
-
//
|
|
89
|
+
// castle by clicking the rook on h1 -> same-color-click branch
|
|
64
90
|
visualMoveInput.onPointerDown({
|
|
65
91
|
type: "touchstart",
|
|
66
|
-
target: {getAttribute: (name) => (name === "data-square" ? "
|
|
92
|
+
target: {getAttribute: (name) => (name === "data-square" ? "h1" : null)},
|
|
67
93
|
touches: [{clientX: 100, clientY: 100}],
|
|
68
94
|
preventDefault: () => {}
|
|
69
95
|
})
|
|
70
96
|
|
|
71
|
-
assert.equal(
|
|
97
|
+
assert.equal(validateCallCount, 1)
|
|
72
98
|
|
|
73
99
|
await new Promise((resolve) => setTimeout(resolve))
|
|
100
|
+
// the move must complete (not get stuck in `clickTo`), firing moveInputFinished exactly once
|
|
101
|
+
assert.equal(moveInputFinishedCount, 1)
|
|
102
|
+
|
|
74
103
|
chessboard.destroy()
|
|
75
104
|
})
|
|
76
105
|
|
|
106
|
+
it("should complete a click-to-move (select, then click destination)", async () => {
|
|
107
|
+
const board = newBoard()
|
|
108
|
+
let finished = 0
|
|
109
|
+
board.enableMoveInput((e) => {
|
|
110
|
+
if (e.type === INPUT_EVENT_TYPE.moveInputFinished) finished++
|
|
111
|
+
return true
|
|
112
|
+
}, COLOR.white)
|
|
113
|
+
const vmi = board.view.visualMoveInput
|
|
114
|
+
|
|
115
|
+
vmi.onPointerDown(mouseDown("e2"))
|
|
116
|
+
vmi.onPointerUp(mouseUp("e2"))
|
|
117
|
+
assert.equal(vmi.moveInputState, STATE_CLICK_TO)
|
|
118
|
+
|
|
119
|
+
vmi.onPointerDown(mouseDown("e4"))
|
|
120
|
+
await tick()
|
|
121
|
+
assert.equal(board.getPiece("e4"), "wp")
|
|
122
|
+
assert.equal(board.getPiece("e2"), null)
|
|
123
|
+
assert.equal(finished, 1)
|
|
124
|
+
board.destroy()
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it("should complete a drag move to a new square", async () => {
|
|
128
|
+
const board = newBoard()
|
|
129
|
+
board.enableMoveInput(() => true, COLOR.white)
|
|
130
|
+
const vmi = board.view.visualMoveInput
|
|
131
|
+
|
|
132
|
+
vmi.onPointerDown(mouseDown("e2", 100, 100))
|
|
133
|
+
vmi.onPointerMove(mouseMove("e4", 100, 140)) // past DRAG_THRESHOLD -> dragTo
|
|
134
|
+
assert.equal(vmi.moveInputState, STATE_DRAG_TO)
|
|
135
|
+
vmi.onPointerUp(mouseUp("e4"))
|
|
136
|
+
await tick()
|
|
137
|
+
assert.equal(board.getPiece("e4"), "wp")
|
|
138
|
+
assert.equal(board.getPiece("e2"), null)
|
|
139
|
+
board.destroy()
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
// Regression for https://github.com/shaack/cm-chessboard/issues/170 (PR #171)
|
|
143
|
+
it("should cancel when a piece is dragged back to its origin", async () => {
|
|
144
|
+
const board = newBoard()
|
|
145
|
+
let canceledReason
|
|
146
|
+
board.enableMoveInput((e) => {
|
|
147
|
+
if (e.type === INPUT_EVENT_TYPE.moveInputCanceled) canceledReason = e.reason
|
|
148
|
+
return true
|
|
149
|
+
}, COLOR.white)
|
|
150
|
+
const vmi = board.view.visualMoveInput
|
|
151
|
+
|
|
152
|
+
vmi.onPointerDown(mouseDown("e2", 100, 100))
|
|
153
|
+
vmi.onPointerMove(mouseMove("e2", 100, 140)) // -> dragTo
|
|
154
|
+
assert.equal(vmi.moveInputState, STATE_DRAG_TO)
|
|
155
|
+
vmi.onPointerUp(mouseUp("e2")) // dropped back on origin
|
|
156
|
+
await tick()
|
|
157
|
+
|
|
158
|
+
assert.equal(canceledReason, MOVE_CANCELED_REASON.draggedBack)
|
|
159
|
+
assert.equal(vmi.moveInputState, STATE_WAIT_FOR_INPUT_START)
|
|
160
|
+
assert.equal(board.getPiece("e2"), "wp") // piece stays put
|
|
161
|
+
board.destroy()
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
// Regression for https://github.com/shaack/cm-chessboard/issues/170
|
|
165
|
+
it("should re-select when clicking another own piece", async () => {
|
|
166
|
+
const board = newBoard()
|
|
167
|
+
let canceledReason
|
|
168
|
+
board.enableMoveInput((e) => {
|
|
169
|
+
if (e.type === INPUT_EVENT_TYPE.validateMoveInput) return false // not a legal move onto own piece
|
|
170
|
+
if (e.type === INPUT_EVENT_TYPE.moveInputCanceled) canceledReason = e.reason
|
|
171
|
+
return true
|
|
172
|
+
}, COLOR.white)
|
|
173
|
+
const vmi = board.view.visualMoveInput
|
|
174
|
+
|
|
175
|
+
vmi.onPointerDown(mouseDown("e2"))
|
|
176
|
+
vmi.onPointerUp(mouseUp("e2"))
|
|
177
|
+
assert.equal(vmi.moveInputState, STATE_CLICK_TO)
|
|
178
|
+
|
|
179
|
+
vmi.onPointerDown(mouseDown("d1")) // own white queen
|
|
180
|
+
assert.equal(canceledReason, MOVE_CANCELED_REASON.clickedAnotherPiece)
|
|
181
|
+
assert.equal(vmi.fromSquare, "d1")
|
|
182
|
+
assert.equal(vmi.moveInputState, STATE_PIECE_CLICKED_THRESHOLD)
|
|
183
|
+
await tick()
|
|
184
|
+
board.destroy()
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
// Regression for https://github.com/shaack/cm-chessboard/pull/173
|
|
188
|
+
it("should cancel an in-progress move on right click", async () => {
|
|
189
|
+
const board = newBoard()
|
|
190
|
+
let canceledReason
|
|
191
|
+
board.enableMoveInput((e) => {
|
|
192
|
+
if (e.type === INPUT_EVENT_TYPE.moveInputCanceled) canceledReason = e.reason
|
|
193
|
+
return true
|
|
194
|
+
}, COLOR.white)
|
|
195
|
+
const vmi = board.view.visualMoveInput
|
|
196
|
+
|
|
197
|
+
vmi.onPointerDown(mouseDown("e2"))
|
|
198
|
+
vmi.onPointerUp(mouseUp("e2"))
|
|
199
|
+
assert.equal(vmi.moveInputState, STATE_CLICK_TO)
|
|
200
|
+
|
|
201
|
+
vmi.onContextMenu({preventDefault: () => {}})
|
|
202
|
+
assert.equal(canceledReason, MOVE_CANCELED_REASON.secondaryClick)
|
|
203
|
+
assert.equal(vmi.moveInputState, STATE_WAIT_FOR_INPUT_START)
|
|
204
|
+
await tick()
|
|
205
|
+
board.destroy()
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it("should ignore input when move input is disabled", () => {
|
|
209
|
+
const board = newBoard()
|
|
210
|
+
const vmi = board.view.visualMoveInput
|
|
211
|
+
vmi.onPointerDown(mouseDown("e2"))
|
|
212
|
+
assert.equal(vmi.moveInputState, STATE_WAIT_FOR_INPUT_START)
|
|
213
|
+
board.destroy()
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it("should ignore a piece of the disabled color", () => {
|
|
217
|
+
const board = newBoard()
|
|
218
|
+
board.enableMoveInput(() => true, COLOR.white)
|
|
219
|
+
const vmi = board.view.visualMoveInput
|
|
220
|
+
vmi.onPointerDown(mouseDown("e7")) // black pawn, black input disabled
|
|
221
|
+
assert.equal(vmi.moveInputState, STATE_WAIT_FOR_INPUT_START)
|
|
222
|
+
board.destroy()
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it("should not start a move when moveInputStarted returns false", () => {
|
|
226
|
+
const board = newBoard()
|
|
227
|
+
board.enableMoveInput((e) => e.type !== INPUT_EVENT_TYPE.moveInputStarted, COLOR.white)
|
|
228
|
+
const vmi = board.view.visualMoveInput
|
|
229
|
+
vmi.onPointerDown(mouseDown("e2"))
|
|
230
|
+
assert.equal(vmi.moveInputState, STATE_WAIT_FOR_INPUT_START)
|
|
231
|
+
board.destroy()
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
// Regression for https://github.com/shaack/cm-chessboard/pull/165
|
|
235
|
+
it("should cancel and keep the new position when the held piece is captured via setPosition", async () => {
|
|
236
|
+
const board = newBoard()
|
|
237
|
+
let canceled = null
|
|
238
|
+
board.enableMoveInput((e) => {
|
|
239
|
+
if (e.type === INPUT_EVENT_TYPE.moveInputCanceled) canceled = {square: e.squareFrom, reason: e.reason}
|
|
240
|
+
return true
|
|
241
|
+
}, COLOR.white)
|
|
242
|
+
const vmi = board.view.visualMoveInput
|
|
243
|
+
vmi.onPointerDown(mouseDown("e2"))
|
|
244
|
+
vmi.onPointerUp(mouseUp("e2"))
|
|
245
|
+
assert.equal(vmi.moveInputState, STATE_CLICK_TO)
|
|
246
|
+
|
|
247
|
+
// the opponent captures the held e2 pawn (e2 now holds a black knight)
|
|
248
|
+
const capturedFen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPnPPP/RNBQKBNR"
|
|
249
|
+
await board.setPosition(capturedFen)
|
|
250
|
+
|
|
251
|
+
assert.equal(canceled && canceled.reason, MOVE_CANCELED_REASON.movedPieceChanged)
|
|
252
|
+
assert.equal(canceled && canceled.square, "e2") // the real start square, not null
|
|
253
|
+
assert.equal(vmi.moveInputState, STATE_WAIT_FOR_INPUT_START)
|
|
254
|
+
// the position must not be corrupted back to the white pawn
|
|
255
|
+
assert.equal("" + board.getPosition(), capturedFen)
|
|
256
|
+
await tick()
|
|
257
|
+
board.destroy()
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
it("should not cancel a normal move completion", async () => {
|
|
261
|
+
const board = newBoard()
|
|
262
|
+
let canceledReason
|
|
263
|
+
let finished = 0
|
|
264
|
+
board.enableMoveInput((e) => {
|
|
265
|
+
if (e.type === INPUT_EVENT_TYPE.moveInputCanceled) canceledReason = e.reason
|
|
266
|
+
if (e.type === INPUT_EVENT_TYPE.moveInputFinished) finished++
|
|
267
|
+
return true
|
|
268
|
+
}, COLOR.white)
|
|
269
|
+
const vmi = board.view.visualMoveInput
|
|
270
|
+
vmi.onPointerDown(mouseDown("e2"))
|
|
271
|
+
vmi.onPointerUp(mouseUp("e2"))
|
|
272
|
+
vmi.onPointerDown(mouseDown("e4")) // completes via moveDone -> movePiece()
|
|
273
|
+
await tick()
|
|
274
|
+
assert.equal(board.getPiece("e4"), "wp")
|
|
275
|
+
assert.equal(canceledReason, undefined) // the guard must prevent a spurious cancel
|
|
276
|
+
assert.equal(finished, 1)
|
|
277
|
+
board.destroy()
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
it("should cancel when the held piece is captured via movePiece or setPiece", async () => {
|
|
281
|
+
// via setPiece
|
|
282
|
+
let board = newBoard()
|
|
283
|
+
let reason
|
|
284
|
+
board.enableMoveInput((e) => {
|
|
285
|
+
if (e.type === INPUT_EVENT_TYPE.moveInputCanceled) reason = e.reason
|
|
286
|
+
return true
|
|
287
|
+
}, COLOR.white)
|
|
288
|
+
let vmi = board.view.visualMoveInput
|
|
289
|
+
vmi.onPointerDown(mouseDown("e2"))
|
|
290
|
+
vmi.onPointerUp(mouseUp("e2"))
|
|
291
|
+
await board.setPiece("e2", "bn")
|
|
292
|
+
assert.equal(reason, MOVE_CANCELED_REASON.movedPieceChanged)
|
|
293
|
+
await tick()
|
|
294
|
+
board.destroy()
|
|
295
|
+
|
|
296
|
+
// via movePiece (a black pawn captures onto e2)
|
|
297
|
+
board = newBoard()
|
|
298
|
+
reason = undefined
|
|
299
|
+
board.enableMoveInput((e) => {
|
|
300
|
+
if (e.type === INPUT_EVENT_TYPE.moveInputCanceled) reason = e.reason
|
|
301
|
+
return true
|
|
302
|
+
}, COLOR.white)
|
|
303
|
+
vmi = board.view.visualMoveInput
|
|
304
|
+
vmi.onPointerDown(mouseDown("e2"))
|
|
305
|
+
vmi.onPointerUp(mouseUp("e2"))
|
|
306
|
+
await board.movePiece("d7", "e2")
|
|
307
|
+
assert.equal(reason, MOVE_CANCELED_REASON.movedPieceChanged)
|
|
308
|
+
await tick()
|
|
309
|
+
board.destroy()
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
it("should cancel an in-progress click selection via cancelMoveInput()", async () => {
|
|
313
|
+
const board = newBoard()
|
|
314
|
+
let canceled = null
|
|
315
|
+
board.enableMoveInput((e) => {
|
|
316
|
+
if (e.type === INPUT_EVENT_TYPE.moveInputCanceled) canceled = {square: e.squareFrom, reason: e.reason}
|
|
317
|
+
return true
|
|
318
|
+
}, COLOR.white)
|
|
319
|
+
const vmi = board.view.visualMoveInput
|
|
320
|
+
vmi.onPointerDown(mouseDown("e2"))
|
|
321
|
+
vmi.onPointerUp(mouseUp("e2"))
|
|
322
|
+
assert.equal(vmi.moveInputState, STATE_CLICK_TO)
|
|
323
|
+
|
|
324
|
+
board.cancelMoveInput()
|
|
325
|
+
assert.equal(canceled && canceled.reason, MOVE_CANCELED_REASON.canceled)
|
|
326
|
+
assert.equal(canceled && canceled.square, "e2")
|
|
327
|
+
assert.equal(vmi.moveInputState, STATE_WAIT_FOR_INPUT_START)
|
|
328
|
+
await tick()
|
|
329
|
+
board.destroy()
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
it("should cancel a drag in progress via cancelMoveInput() without moving the piece", async () => {
|
|
333
|
+
const board = newBoard()
|
|
334
|
+
board.enableMoveInput(() => true, COLOR.white)
|
|
335
|
+
const vmi = board.view.visualMoveInput
|
|
336
|
+
vmi.onPointerDown(mouseDown("e2", 100, 100))
|
|
337
|
+
vmi.onPointerMove(mouseMove("e4", 100, 140)) // -> dragTo
|
|
338
|
+
assert.equal(vmi.moveInputState, STATE_DRAG_TO)
|
|
339
|
+
|
|
340
|
+
board.cancelMoveInput()
|
|
341
|
+
assert.equal(vmi.moveInputState, STATE_WAIT_FOR_INPUT_START)
|
|
342
|
+
assert.equal(board.getPiece("e2"), "wp")
|
|
343
|
+
assert.equal(board.getPiece("e4"), null)
|
|
344
|
+
await tick()
|
|
345
|
+
board.destroy()
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
it("should be a no-op when cancelMoveInput() is called with no move in progress", () => {
|
|
349
|
+
const board = newBoard()
|
|
350
|
+
let canceledCount = 0
|
|
351
|
+
board.enableMoveInput((e) => {
|
|
352
|
+
if (e.type === INPUT_EVENT_TYPE.moveInputCanceled) canceledCount++
|
|
353
|
+
return true
|
|
354
|
+
}, COLOR.white)
|
|
355
|
+
board.cancelMoveInput()
|
|
356
|
+
assert.equal(canceledCount, 0)
|
|
357
|
+
assert.equal(board.view.visualMoveInput.moveInputState, STATE_WAIT_FOR_INPUT_START)
|
|
358
|
+
board.destroy()
|
|
359
|
+
})
|
|
360
|
+
|
|
77
361
|
})
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Author and copyright: Stefan Haack (https://shaack.com)
|
|
3
|
+
* Repository: https://github.com/shaack/cm-chessboard
|
|
4
|
+
* License: MIT, see file 'LICENSE'
|
|
5
|
+
*
|
|
6
|
+
* Optional headless test runner. Serves the project over a tiny static server
|
|
7
|
+
* and runs test/index.html in headless Chrome, then prints the Teevi summary
|
|
8
|
+
* and exits non-zero on failure.
|
|
9
|
+
*
|
|
10
|
+
* Puppeteer is intentionally NOT a project dependency (cm-chessboard ships
|
|
11
|
+
* without dependencies). Install it globally to use this runner:
|
|
12
|
+
*
|
|
13
|
+
* npm install -g puppeteer
|
|
14
|
+
* npm run test:headless
|
|
15
|
+
*
|
|
16
|
+
* The regular `npm test` just points you at test/index.html in a browser and
|
|
17
|
+
* needs nothing installed.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import {createServer} from "http"
|
|
21
|
+
import {createRequire} from "module"
|
|
22
|
+
import {execSync} from "child_process"
|
|
23
|
+
import {readFile} from "fs/promises"
|
|
24
|
+
import {fileURLToPath} from "url"
|
|
25
|
+
import {dirname, join, normalize, extname} from "path"
|
|
26
|
+
|
|
27
|
+
const projectRoot = normalize(join(dirname(fileURLToPath(import.meta.url)), ".."))
|
|
28
|
+
|
|
29
|
+
// Resolve the globally installed puppeteer without adding a project dependency.
|
|
30
|
+
function loadPuppeteer() {
|
|
31
|
+
let globalRoot
|
|
32
|
+
try {
|
|
33
|
+
globalRoot = execSync("npm root -g", {encoding: "utf8"}).trim()
|
|
34
|
+
} catch {
|
|
35
|
+
globalRoot = ""
|
|
36
|
+
}
|
|
37
|
+
for (const base of [globalRoot + "/", projectRoot + "/"]) {
|
|
38
|
+
try {
|
|
39
|
+
return createRequire(base)("puppeteer")
|
|
40
|
+
} catch { /* try next */ }
|
|
41
|
+
}
|
|
42
|
+
console.error(
|
|
43
|
+
"\nCould not find puppeteer. This headless runner needs it installed globally:\n" +
|
|
44
|
+
" npm install -g puppeteer\n" +
|
|
45
|
+
"Or just open test/index.html in a browser (see `npm test`).\n")
|
|
46
|
+
process.exit(2)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const MIME = {
|
|
50
|
+
".html": "text/html", ".js": "text/javascript", ".mjs": "text/javascript",
|
|
51
|
+
".css": "text/css", ".svg": "image/svg+xml", ".json": "application/json",
|
|
52
|
+
".png": "image/png", ".map": "application/json"
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function startServer() {
|
|
56
|
+
const server = createServer(async (req, res) => {
|
|
57
|
+
const urlPath = decodeURIComponent(req.url.split("?")[0])
|
|
58
|
+
const filePath = normalize(join(projectRoot, urlPath))
|
|
59
|
+
if (!filePath.startsWith(projectRoot)) { // block path traversal
|
|
60
|
+
res.writeHead(403).end()
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
const body = await readFile(filePath)
|
|
65
|
+
res.writeHead(200, {"Content-Type": MIME[extname(filePath)] || "application/octet-stream"})
|
|
66
|
+
res.end(body)
|
|
67
|
+
} catch {
|
|
68
|
+
res.writeHead(404).end()
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
return new Promise((resolve) => {
|
|
72
|
+
server.listen(0, "127.0.0.1", () => resolve({server, port: server.address().port}))
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const puppeteer = loadPuppeteer()
|
|
77
|
+
const {server, port} = await startServer()
|
|
78
|
+
const url = `http://127.0.0.1:${port}/test/index.html`
|
|
79
|
+
|
|
80
|
+
let exitCode = 1
|
|
81
|
+
const browser = await puppeteer.launch({headless: "new"})
|
|
82
|
+
try {
|
|
83
|
+
const page = await browser.newPage()
|
|
84
|
+
const errors = []
|
|
85
|
+
page.on("pageerror", (e) => errors.push(e.message))
|
|
86
|
+
await page.goto(url, {waitUntil: "networkidle0", timeout: 30000})
|
|
87
|
+
await page.waitForFunction(
|
|
88
|
+
() => /All \d+ tests passed|\d+ tests, \d+ passed, \d+ failed/.test(document.body.innerText),
|
|
89
|
+
{timeout: 30000})
|
|
90
|
+
const {summary, fails} = await page.evaluate(() => {
|
|
91
|
+
const text = document.body.innerText
|
|
92
|
+
const m = text.match(/All \d+ tests passed|\d+ tests, \d+ passed, \d+ failed/)
|
|
93
|
+
const fails = []
|
|
94
|
+
document.querySelectorAll("div").forEach((d) => {
|
|
95
|
+
if (d.innerText && d.innerText.includes("→ fail")) {
|
|
96
|
+
fails.push(d.innerText.replace(/\s+/g, " ").trim().slice(0, 500))
|
|
97
|
+
}
|
|
98
|
+
})
|
|
99
|
+
return {summary: m ? m[0] : "NO SUMMARY", fails}
|
|
100
|
+
})
|
|
101
|
+
console.log(summary)
|
|
102
|
+
for (const f of fails) console.log(" FAIL: " + f)
|
|
103
|
+
for (const e of errors.slice(0, 10)) console.log(" pageerror: " + e)
|
|
104
|
+
exitCode = /failed|NO SUMMARY/.test(summary) ? 1 : 0
|
|
105
|
+
} finally {
|
|
106
|
+
await browser.close()
|
|
107
|
+
server.close()
|
|
108
|
+
}
|
|
109
|
+
process.exit(exitCode)
|
package/test/index.html
CHANGED
|
@@ -20,11 +20,13 @@
|
|
|
20
20
|
<div class="board" id="TestBoard"></div>
|
|
21
21
|
<div class="board" id="TestPosition"></div>
|
|
22
22
|
<div class="board" id="TestMarkers"></div>
|
|
23
|
+
<div class="board" id="TestArrows"></div>
|
|
23
24
|
<script type="module">
|
|
24
25
|
import {teevi} from "../node_modules/teevi/src/teevi.js"
|
|
25
26
|
import "./TestChessboard.js"
|
|
26
27
|
import "./TestPiecesAnimation.js"
|
|
27
28
|
import "./TestMarkers.js"
|
|
29
|
+
import "./TestArrows.js"
|
|
28
30
|
import "./TestPosition.js"
|
|
29
31
|
import "./TestVisualMoveInput.js"
|
|
30
32
|
teevi.run()
|