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 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
- `event.probe` is `true` when the target square still holds one of the mover's own pieces (e.g. clicking another own
211
- piece, or a chess960 castling target). A falsy result then means "re-select that piece" rather than an illegal move,
212
- so you can suppress illegal-move feedback when `event.probe` is set.
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.17",
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, probe = false) {
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, probe = false) {
63
- const result = this.view.validateMoveInputCallback(fromSquare, toSquare, probe)
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 (this.toSquare && this.validateMoveInputCallback(this.fromSquare, this.toSquare)) {
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 castling. This is only a probe:
302
- // a falsy result means the user re-selected another own
303
- // piece, not that an illegal move was attempted.
304
- const result = this.validateMoveInputCallback(this.fromSquare, square, true)
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
+ })
@@ -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)
@@ -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
  })
@@ -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
- it("should mark the same-color reselect validation as a probe", async () => {
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 probeSeen
71
+ let validateCallCount = 0
72
+ let moveInputFinishedCount = 0
48
73
  chessboard.enableMoveInput((event) => {
49
74
  if (event.type === INPUT_EVENT_TYPE.validateMoveInput) {
50
- probeSeen = event.probe
51
- return false // reject -> this is a re-selection, not a real move
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 e2 pawn
58
- // (moveInputStartedCallback sets up the internal moveInputProcess task)
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 = "e2"
87
+ visualMoveInput.fromSquare = "e1"
62
88
 
63
- // click another own (white) piece, the d1 queen -> chess960 probe branch
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" ? "d1" : null)},
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(probeSeen, true)
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()