Files
blind_chess/packages/server/test/unit/bot/candidates.test.ts
T
2026-04-28 14:07:01 -04:00

102 lines
4.1 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import { Chess } from 'chess.js';
import { legalCandidates } from '../../../src/bot/candidates.js';
import type { Game } from '../../../src/state.js';
import { RATE_LIMIT } from '../../../src/state.js';
function makeGame(mode: 'blind' | 'vanilla', fen?: string): Game {
return {
id: 'cand0001',
mode,
highlightingEnabled: false,
status: 'active',
createdAt: Date.now(),
chess: fen ? new Chess(fen) : new Chess(),
moveHistory: [],
announcements: [],
players: {
w: { token: 'w'.repeat(24), socket: null, joinedAt: 0,
rateBucket: { tokens: RATE_LIMIT.capacity, last: 0 } },
b: { token: 'b'.repeat(24), socket: null, joinedAt: 0,
rateBucket: { tokens: RATE_LIMIT.capacity, last: 0 } },
},
armed: null,
drawOffer: null,
disconnectAt: {},
lastBroadcastIdx: { w: 0, b: 0 },
};
}
describe('legalCandidates / vanilla', () => {
it('starting position: 20 candidates for white', () => {
const game = makeGame('vanilla');
const candidates = legalCandidates(game, 'w');
expect(candidates.length).toBe(20);
});
it('returns from/to on each candidate', () => {
const game = makeGame('vanilla');
const candidates = legalCandidates(game, 'w');
expect(candidates.every((c) => c.from && c.to)).toBe(true);
});
it('vanilla excludes pinned-piece moves (chess.js filters self-check)', () => {
// White king e1, white bishop e2, black rook e8. Bishop is pinned.
const game = makeGame('vanilla', '4r2k/8/8/8/8/8/4B3/4K3 w - - 0 1');
const candidates = legalCandidates(game, 'w');
// Bishop on e2 has zero legal moves (any move drops the king to check).
expect(candidates.find((c) => c.from === 'e2')).toBeUndefined();
});
it('vanilla expands all 4 promotion options', () => {
// White pawn on a7, ready to promote.
const game = makeGame('vanilla', '4k3/P7/8/8/8/8/8/4K3 w - - 0 1');
const candidates = legalCandidates(game, 'w').filter((c) => c.from === 'a7');
expect(candidates.map((c) => c.promotion).sort()).toEqual(['b', 'n', 'q', 'r']);
});
});
describe('legalCandidates / blind', () => {
it('starting position: 34 geometric candidates for white', () => {
// 8 pawns: edge pawns (a2, h2) return 3 moves each (forward 1, forward 2, 1 diagonal),
// interior pawns (b2-g2) return 4 moves each (forward 1, forward 2, 2 diagonals).
// Total pawn: 2*3 + 6*4 = 30.
// 2 knights: 2 moves each = 4.
// Total: 34.
const game = makeGame('blind');
const candidates = legalCandidates(game, 'w');
expect(candidates.length).toBe(34);
});
it('blind INCLUDES pinned-piece moves (geometric does not know about pins)', () => {
// Same pinned-bishop position. Geometric move-gen sees no own piece blocking;
// bishop can geometrically reach d3, c4, b5, a6, f3, etc.
const game = makeGame('blind', '4r2k/8/8/8/8/8/4B3/4K3 w - - 0 1');
const candidates = legalCandidates(game, 'w');
expect(candidates.some((c) => c.from === 'e2')).toBe(true);
});
it('blind expands all 4 promotion options for own pawn', () => {
const game = makeGame('blind', '4k3/P7/8/8/8/8/8/4K3 w - - 0 1');
const candidates = legalCandidates(game, 'w').filter((c) => c.from === 'a7' && c.to === 'a8');
expect(candidates.map((c) => c.promotion).sort()).toEqual(['b', 'n', 'q', 'r']);
});
it('blind ignores whose turn it is (returns moves for either color)', () => {
// Vanilla path filters by chess.js .moves() which respects toMove. Blind
// path iterates own pieces directly, so black candidates exist on move 0.
const game = makeGame('blind');
const candidates = legalCandidates(game, 'b');
// Same geometric move count as white: 34.
expect(candidates.length).toBe(34);
});
it('zero own pieces = zero candidates (degenerate)', () => {
// FEN with only black king + pieces — but FEN must be valid, kings required.
const game = makeGame('blind', '4k3/8/8/8/8/8/8/4K3 w - - 0 1');
const black = legalCandidates(game, 'b');
// Black king on e8 has 5 geometric king moves (d8, f8, d7, e7, f7).
expect(black.length).toBe(5);
});
});