import { describe, it, expect, beforeEach, spyOn } from "bun:test"; import { chessPlugin } from "./chess.plugin"; import type { ChessState, ChessAction, ChessPlayerView, ChessSpectatorView } from "./chess.types"; import type { GameResult } from "../types"; // ── Helpers ── function createState(overrides?: Partial[1]>): ChessState { // Use a fixed seed by mocking Math.random so white/black assignment is deterministic const spy = spyOn(Math, "random").mockReturnValue(0.1); // < 0.5 → players[0] = white const state = chessPlugin.createInitialState(["alice", "bob"], overrides); spy.mockRestore(); return state; } function act(state: ChessState, action: ChessAction, playerId: string): ChessState { const result = chessPlugin.handleAction(state, action, playerId); if (!result.ok) throw new Error(`Action failed: ${result.error}`); return result.state; } function tryAct(state: ChessState, action: ChessAction, playerId: string): GameResult { return chessPlugin.handleAction(state, action, playerId); } /** Play a Scholar's Mate: 1. e4 e5 2. Qh5 Nc6 3. Bc4 Nf6 4. Qxf7# */ function playScholarsMate(state: ChessState): ChessState { const white = state.players.white; const black = state.players.black; state = act(state, { type: "move", from: "e2", to: "e4" }, white); state = act(state, { type: "move", from: "e7", to: "e5" }, black); state = act(state, { type: "move", from: "d1", to: "h5" }, white); state = act(state, { type: "move", from: "b8", to: "c6" }, black); state = act(state, { type: "move", from: "f1", to: "c4" }, white); state = act(state, { type: "move", from: "g8", to: "f6" }, black); state = act(state, { type: "move", from: "h5", to: "f7" }, white); // Checkmate return state; } // ── Tests ── describe("chessPlugin", () => { describe("createInitialState", () => { it("should create a valid starting position", () => { const state = createState(); expect(state.fen).toBe("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"); expect(state.result).toBeNull(); expect(state.drawOffer).toBeNull(); expect(state.moveHistory).toEqual([]); }); it("should assign players to white and black", () => { const state = createState(); expect(state.players.white).toBe("alice"); expect(state.players.black).toBe("bob"); }); it("should randomize color assignment", () => { const spy = spyOn(Math, "random").mockReturnValue(0.9); // > 0.5 → swap const state = chessPlugin.createInitialState(["alice", "bob"]); spy.mockRestore(); expect(state.players.white).toBe("bob"); expect(state.players.black).toBe("alice"); }); it("should default to blitz 5+3 time control", () => { const state = createState(); expect(state.clock).not.toBeNull(); expect(state.clock!.white).toBe(5 * 60_000); expect(state.clock!.black).toBe(5 * 60_000); expect(state.clock!.increment).toBe(3_000); }); it("should apply custom time control from options", () => { const state = createState({ timeControl: "bullet_1_0" }); expect(state.clock!.white).toBe(60_000); expect(state.clock!.increment).toBe(0); }); it("should create no clock when timeControl is 'none'", () => { const state = createState({ timeControl: "none" }); expect(state.clock).toBeNull(); }); it("should fall back to blitz 5+3 for unknown time control", () => { const state = createState({ timeControl: "invalid_key" }); expect(state.clock!.white).toBe(5 * 60_000); expect(state.clock!.increment).toBe(3_000); }); }); describe("handleAction — move", () => { let state: ChessState; beforeEach(() => { state = createState({ timeControl: "none" }); }); it("should accept a legal move", () => { const result = tryAct(state, { type: "move", from: "e2", to: "e4" }, "alice"); expect(result.ok).toBe(true); if (result.ok) { // After 1. e4, FEN should show pawn on e4 (4th rank) and it's black's turn expect(result.state.fen).toContain("4P3"); // Pawn on e4 in FEN notation expect(result.state.fen).toContain(" b "); // Black to move expect(result.state.moveHistory).toHaveLength(1); expect(result.state.moveHistory[0]!.san).toBe("e4"); expect(result.state.moveHistory[0]!.color).toBe("w"); } }); it("should reject a move when it's not your turn", () => { const result = tryAct(state, { type: "move", from: "e7", to: "e5" }, "bob"); // bob is black, white moves first — but bob is trying to move a black piece on white's turn // Actually, the check is playerColor !== turn, so bob (black) can't move when turn is white expect(result.ok).toBe(false); if (!result.ok) expect(result.error).toBe("It's not your turn"); }); it("should reject an illegal move", () => { const result = tryAct(state, { type: "move", from: "e2", to: "e5" }, "alice"); expect(result.ok).toBe(false); if (!result.ok) expect(result.error).toBe("Illegal move"); }); it("should reject a move from a nonsense square", () => { const result = tryAct(state, { type: "move", from: "z9", to: "z8" }, "alice"); expect(result.ok).toBe(false); if (!result.ok) expect(result.error).toBe("Illegal move"); }); it("should detect checkmate", () => { const final = playScholarsMate(state); expect(final.result).toBe("white"); expect(final.resultReason).toBe("Checkmate"); }); it("should clear a pending draw offer on move", () => { state = act(state, { type: "offer_draw" }, "alice"); expect(state.drawOffer).toBe("white"); state = act(state, { type: "move", from: "e2", to: "e4" }, "alice"); expect(state.drawOffer).toBeNull(); }); it("should track move history for both colors", () => { state = act(state, { type: "move", from: "e2", to: "e4" }, "alice"); state = act(state, { type: "move", from: "e7", to: "e5" }, "bob"); expect(state.moveHistory).toHaveLength(2); expect(state.moveHistory[0]!.color).toBe("w"); expect(state.moveHistory[1]!.color).toBe("b"); }); it("should handle pawn promotion", () => { // Set up a position where white pawn is on the 7th rank const promoState: ChessState = { ...state, fen: "8/P7/8/8/8/8/8/4K2k w - - 0 1", // White pawn on a7, kings }; const result = tryAct(promoState, { type: "move", from: "a7", to: "a8", promotion: "q" }, "alice"); expect(result.ok).toBe(true); if (result.ok) { expect(result.state.fen).toContain("Q"); // Promoted to queen } }); it("should detect stalemate", () => { // Classic stalemate: black king in corner, no legal moves const stalemateState: ChessState = { ...state, fen: "k7/2Q5/1K6/8/8/8/8/8 w - - 0 1", }; // White plays Qc8 — but that's check not stalemate. Use Qb7 stalemate pattern. // Actually use: king a8, white queen a6, white king c8 → Qa7# is checkmate not stalemate // Classic stalemate: Ka1, Qb3 with Kc2 const state2: ChessState = { ...state, fen: "k7/8/1K6/8/8/8/8/2Q5 w - - 0 1", // Kc1 → Qc7 creates stalemate? No. }; // Let's use a known stalemate position where it's black to move and has no legal moves const state3: ChessState = { ...state, fen: "k7/2Q5/K7/8/8/8/8/8 b - - 0 1", // Black to move, king on a8, blocked }; // In this position it's actually stalemate for black — black king has no legal moves // But wait, Qa7 is not played, the queen is on c7. // Ka8 can go to b8 (not attacked by Qc7? Qc7 attacks b8? c7 queen attacks b8 yes). // Ka8 also blocked by Ka6. So a8 king: a7 attacked by K and Q, b8 attacked by Q, b7 attacked by Q. // That IS stalemate — black king has no legal moves and is not in check. // But we need white to deliver the stalemate on their move, then the position detection happens. // Actually, chess.js checks stalemate from the new position. So we need a position where // after white's move, it's black's turn and black is stalemated. // Simpler: use position where white's move leads to stalemate const stalemateSetup: ChessState = { ...state, fen: "k7/8/1K6/2Q5/8/8/8/8 w - - 0 1", }; // White plays Qc7 → position is k7/2Q5/1K6/8/8/8/8/8 b - - 1 1 // Black king on a8: a7 attacked by K+Q, b8 attacked by Q, b7 attacked by K → stalemate! const result = tryAct(stalemateSetup, { type: "move", from: "c5", to: "c7" }, "alice"); expect(result.ok).toBe(true); if (result.ok) { expect(result.state.result).toBe("draw"); expect(result.state.resultReason).toBe("Stalemate"); } }); it("should detect insufficient material", () => { // King vs King — after a capture leaves only kings const kvk: ChessState = { ...state, fen: "8/8/8/8/8/8/1p6/K1k5 b - - 0 1", // Black pawn can't create insufficiency easily }; // Simpler: just set up K vs K directly after a capture // Actually, let's set up KN vs K where white captures last black piece const setup: ChessState = { ...state, fen: "8/8/8/8/8/2n5/8/KN5k w - - 0 1", }; // White Nb1 captures Nc3 → only K+N vs K (insufficient material? Actually K+N vs K IS insufficient) const result = tryAct(setup, { type: "move", from: "b1", to: "c3" }, "alice"); expect(result.ok).toBe(true); if (result.ok) { expect(result.state.result).toBe("draw"); expect(result.state.resultReason).toBe("Insufficient material"); } }); }); describe("handleAction — move with clock", () => { it("should deduct time and add increment on move", () => { const state = createState({ timeControl: "blitz_5_3" }); const now = Date.now(); // Simulate 1 second passing const stateWithOldClock: ChessState = { ...state, clock: { ...state.clock!, lastMoveAt: now - 1000 }, }; const result = tryAct(stateWithOldClock, { type: "move", from: "e2", to: "e4" }, "alice"); expect(result.ok).toBe(true); if (result.ok) { // White had 300_000ms, spent ~1000ms, got 3000ms increment // Should be approximately 300_000 - 1000 + 3000 = 302_000 expect(result.state.clock!.white).toBeGreaterThan(300_000); expect(result.state.clock!.white).toBeLessThanOrEqual(302_100); // small tolerance } }); it("should trigger timeout if player's clock has expired when they try to move", () => { const state = createState({ timeControl: "bullet_1_0" }); const stateWithExpiredClock: ChessState = { ...state, clock: { ...state.clock!, white: 500, lastMoveAt: Date.now() - 1000 }, }; // White tries to move but their clock is at 500ms with 1000ms elapsed → expired const result = tryAct(stateWithExpiredClock, { type: "move", from: "e2", to: "e4" }, "alice"); expect(result.ok).toBe(true); if (result.ok) { expect(result.state.result).toBe("black"); // Black wins on time expect(result.state.resultReason).toBe("Timeout"); } }); }); describe("handleAction — resign", () => { it("should let white resign (black wins)", () => { const state = createState({ timeControl: "none" }); const result = tryAct(state, { type: "resign" }, "alice"); expect(result.ok).toBe(true); if (result.ok) { expect(result.state.result).toBe("black"); expect(result.state.resultReason).toBe("Resignation"); } }); it("should let black resign (white wins)", () => { const state = createState({ timeControl: "none" }); const result = tryAct(state, { type: "resign" }, "bob"); expect(result.ok).toBe(true); if (result.ok) { expect(result.state.result).toBe("white"); expect(result.state.resultReason).toBe("Resignation"); } }); }); describe("handleAction — draw offers", () => { let state: ChessState; beforeEach(() => { state = createState({ timeControl: "none" }); }); it("should allow offering a draw", () => { const result = tryAct(state, { type: "offer_draw" }, "alice"); expect(result.ok).toBe(true); if (result.ok) expect(result.state.drawOffer).toBe("white"); }); it("should reject duplicate draw offer from same player", () => { state = act(state, { type: "offer_draw" }, "alice"); const result = tryAct(state, { type: "offer_draw" }, "alice"); expect(result.ok).toBe(false); if (!result.ok) expect(result.error).toBe("You already offered a draw"); }); it("should allow opponent to accept a draw", () => { state = act(state, { type: "offer_draw" }, "alice"); const result = tryAct(state, { type: "accept_draw" }, "bob"); expect(result.ok).toBe(true); if (result.ok) { expect(result.state.result).toBe("draw"); expect(result.state.resultReason).toBe("Agreement"); expect(result.state.drawOffer).toBeNull(); } }); it("should reject accepting when no draw offer exists", () => { const result = tryAct(state, { type: "accept_draw" }, "bob"); expect(result.ok).toBe(false); if (!result.ok) expect(result.error).toBe("No draw offer to accept"); }); it("should reject accepting your own draw offer", () => { state = act(state, { type: "offer_draw" }, "alice"); const result = tryAct(state, { type: "accept_draw" }, "alice"); expect(result.ok).toBe(false); if (!result.ok) expect(result.error).toBe("No draw offer to accept"); }); it("should allow opponent to decline a draw", () => { state = act(state, { type: "offer_draw" }, "alice"); const result = tryAct(state, { type: "decline_draw" }, "bob"); expect(result.ok).toBe(true); if (result.ok) expect(result.state.drawOffer).toBeNull(); }); it("should reject declining when no draw offer exists", () => { const result = tryAct(state, { type: "decline_draw" }, "bob"); expect(result.ok).toBe(false); if (!result.ok) expect(result.error).toBe("No draw offer to decline"); }); it("should reject declining your own draw offer", () => { state = act(state, { type: "offer_draw" }, "alice"); const result = tryAct(state, { type: "decline_draw" }, "alice"); expect(result.ok).toBe(false); if (!result.ok) expect(result.error).toBe("No draw offer to decline"); }); }); describe("handleAction — claim_timeout", () => { it("should reject when there is no clock", () => { const state = createState({ timeControl: "none" }); const result = tryAct(state, { type: "claim_timeout" }, "alice"); expect(result.ok).toBe(false); if (!result.ok) expect(result.error).toBe("No clock in this game"); }); it("should reject when opponent still has time", () => { const state = createState({ timeControl: "blitz_5_3" }); const result = tryAct(state, { type: "claim_timeout" }, "bob"); expect(result.ok).toBe(false); if (!result.ok) expect(result.error).toBe("Opponent still has time remaining"); }); it("should succeed when opponent's time has expired", () => { const state = createState({ timeControl: "bullet_1_0" }); // White's clock is 60_000ms — set lastMoveAt far in the past const expiredState: ChessState = { ...state, clock: { ...state.clock!, lastMoveAt: Date.now() - 120_000 }, }; // It's white's turn, so white's clock is ticking. Bob (black) claims timeout. const result = tryAct(expiredState, { type: "claim_timeout" }, "bob"); expect(result.ok).toBe(true); if (result.ok) { expect(result.state.result).toBe("black"); expect(result.state.resultReason).toBe("Timeout"); } }); }); describe("handleAction — guards", () => { it("should reject any action after game is over", () => { let state = createState({ timeControl: "none" }); state = act(state, { type: "resign" }, "alice"); expect(state.result).toBe("black"); const moveResult = tryAct(state, { type: "move", from: "e2", to: "e4" }, "bob"); expect(moveResult.ok).toBe(false); if (!moveResult.ok) expect(moveResult.error).toBe("Game is already over"); const resignResult = tryAct(state, { type: "resign" }, "bob"); expect(resignResult.ok).toBe(false); const drawResult = tryAct(state, { type: "offer_draw" }, "bob"); expect(drawResult.ok).toBe(false); }); it("should reject actions from a non-player", () => { const state = createState({ timeControl: "none" }); const result = tryAct(state, { type: "move", from: "e2", to: "e4" }, "charlie"); expect(result.ok).toBe(false); if (!result.ok) expect(result.error).toBe("You are not a player in this game"); }); }); describe("getPlayerView", () => { it("should return the correct color for each player", () => { const state = createState({ timeControl: "none" }); const aliceView = chessPlugin.getPlayerView(state, "alice") as ChessPlayerView; const bobView = chessPlugin.getPlayerView(state, "bob") as ChessPlayerView; expect(aliceView.myColor).toBe("white"); expect(bobView.myColor).toBe("black"); }); it("should include legal moves only for the active player", () => { const state = createState({ timeControl: "none" }); const whiteView = chessPlugin.getPlayerView(state, "alice") as ChessPlayerView; const blackView = chessPlugin.getPlayerView(state, "bob") as ChessPlayerView; expect(whiteView.legalMoves.length).toBeGreaterThan(0); // White has 20 opening moves expect(blackView.legalMoves).toEqual([]); // Not black's turn }); it("should return 20 legal moves in starting position", () => { const state = createState({ timeControl: "none" }); const view = chessPlugin.getPlayerView(state, "alice") as ChessPlayerView; expect(view.legalMoves.length).toBe(20); // 16 pawn moves + 4 knight moves }); it("should return no legal moves after game is over", () => { let state = createState({ timeControl: "none" }); state = playScholarsMate(state); const view = chessPlugin.getPlayerView(state, "alice") as ChessPlayerView; expect(view.legalMoves).toEqual([]); }); it("should detect check in the view", () => { const state = createState({ timeControl: "none" }); // After 1. e4 e5 2. Qh5 — queen attacks f7, not check yet. // Use a direct check position: white queen on e7 checking black king on e8 const checkState: ChessState = { ...state, fen: "rnbqkbnr/ppppQppp/8/8/4P3/8/PPPP1PPP/RNB1KBNR b KQkq - 0 1", }; const view = chessPlugin.getPlayerView(checkState, "bob") as ChessPlayerView; expect(view.isCheck).toBe(true); }); it("should include clock with activeColor when game is in progress", () => { const state = createState({ timeControl: "blitz_5_3" }); const view = chessPlugin.getPlayerView(state, "alice") as ChessPlayerView; expect(view.clock).not.toBeNull(); expect(view.clock!.activeColor).toBe("white"); }); it("should set clock activeColor to null when game is over", () => { let state = createState({ timeControl: "blitz_5_3" }); state = act(state, { type: "resign" }, "alice"); const view = chessPlugin.getPlayerView(state, "alice") as ChessPlayerView; expect(view.clock!.activeColor).toBeNull(); }); it("should default to white for an unknown player", () => { const state = createState({ timeControl: "none" }); const view = chessPlugin.getPlayerView(state, "unknown") as ChessPlayerView; expect(view.myColor).toBe("white"); }); }); describe("getSpectatorView", () => { it("should include both player identifiers", () => { const state = createState({ timeControl: "none" }); const view = chessPlugin.getSpectatorView(state) as ChessSpectatorView; expect(view.players.white).toBe("alice"); expect(view.players.black).toBe("bob"); }); it("should include current turn", () => { const state = createState({ timeControl: "none" }); const view = chessPlugin.getSpectatorView(state) as ChessSpectatorView; expect(view.turn).toBe("white"); }); it("should include clock data when present", () => { const state = createState({ timeControl: "rapid_10_0" }); const view = chessPlugin.getSpectatorView(state) as ChessSpectatorView; expect(view.clock).not.toBeNull(); expect(view.clock!.increment).toBe(0); }); it("should have null clock when time control is none", () => { const state = createState({ timeControl: "none" }); const view = chessPlugin.getSpectatorView(state) as ChessSpectatorView; expect(view.clock).toBeNull(); }); }); describe("isGameOver", () => { it("should return null when game is in progress", () => { const state = createState({ timeControl: "none" }); expect(chessPlugin.isGameOver!(state)).toBeNull(); }); it("should return winner for white victory", () => { let state = createState({ timeControl: "none" }); state = act(state, { type: "resign" }, "bob"); // Bob (black) resigns const result = chessPlugin.isGameOver!(state); expect(result).not.toBeNull(); expect(result!.winner).toBe("alice"); // White player ID expect(result!.reason).toBe("Resignation"); }); it("should return winner for black victory", () => { let state = createState({ timeControl: "none" }); state = act(state, { type: "resign" }, "alice"); // Alice (white) resigns const result = chessPlugin.isGameOver!(state); expect(result).not.toBeNull(); expect(result!.winner).toBe("bob"); // Black player ID }); it("should return null winner for draw", () => { let state = createState({ timeControl: "none" }); state = act(state, { type: "offer_draw" }, "alice"); state = act(state, { type: "accept_draw" }, "bob"); const result = chessPlugin.isGameOver!(state); expect(result).not.toBeNull(); expect(result!.winner).toBeNull(); expect(result!.reason).toBe("Agreement"); }); it("should return checkmate result with correct winner", () => { let state = createState({ timeControl: "none" }); state = playScholarsMate(state); const result = chessPlugin.isGameOver!(state); expect(result).not.toBeNull(); expect(result!.winner).toBe("alice"); // White wins by checkmate expect(result!.reason).toBe("Checkmate"); }); }); describe("onPlayerDisconnect", () => { it("should award victory to opponent when player disconnects", () => { const state = createState({ timeControl: "none" }); const result = chessPlugin.onPlayerDisconnect!(state, "alice"); expect(result.result).toBe("black"); expect(result.resultReason).toBe("Opponent disconnected"); }); it("should award victory to white when black disconnects", () => { const state = createState({ timeControl: "none" }); const result = chessPlugin.onPlayerDisconnect!(state, "bob"); expect(result.result).toBe("white"); expect(result.resultReason).toBe("Opponent disconnected"); }); it("should not change state if game is already over", () => { let state = createState({ timeControl: "none" }); state = act(state, { type: "resign" }, "alice"); const result = chessPlugin.onPlayerDisconnect!(state, "bob"); expect(result.result).toBe("black"); // Original result preserved expect(result.resultReason).toBe("Resignation"); // Not overwritten }); it("should not change state for unknown player", () => { const state = createState({ timeControl: "none" }); const result = chessPlugin.onPlayerDisconnect!(state, "unknown"); expect(result.result).toBeNull(); // No change }); }); describe("plugin metadata", () => { it("should have correct slug and name", () => { expect(chessPlugin.slug).toBe("chess"); expect(chessPlugin.name).toBe("Chess"); }); it("should require exactly 2 players", () => { expect(chessPlugin.minPlayers).toBe(2); expect(chessPlugin.maxPlayers).toBe(2); }); }); });