Files
zzg b7da55371a
ci / test (push) Canceled after 0s
initial commit
2026-09-25 21:57:09 -07:00

285 lines
8.8 KiB
Go

package engine
import (
"context"
"testing"
"time"
"github.com/zg/chess/chess"
)
func search(t *testing.T, fen string, depth int) SearchResult {
t.Helper()
b, err := chess.ParseFEN(fen)
if err != nil {
t.Fatalf("FEN: %v", err)
}
return New().Search(context.Background(), b, Options{MaxDepth: depth, MaxTime: 20 * time.Second})
}
func TestEvaluateStartingPositionIsBalanced(t *testing.T) {
if got := Evaluate(chess.NewBoard()); got != 0 {
t.Errorf("starting position evaluates to %d, want 0", got)
}
}
// Evaluation is from the side to move's point of view, so the same position
// with the other side to move must negate.
func TestEvaluateIsSymmetric(t *testing.T) {
// White is a queen up.
white, _ := chess.ParseFEN("4k3/8/8/8/8/8/8/3QK3 w - - 0 1")
black, _ := chess.ParseFEN("4k3/8/8/8/8/8/8/3QK3 b - - 0 1")
w, bl := Evaluate(white), Evaluate(black)
if w <= 0 {
t.Errorf("white to move, a queen up, evaluates %d; want positive", w)
}
if bl >= 0 {
t.Errorf("black to move, a queen down, evaluates %d; want negative", bl)
}
if w != -bl {
t.Errorf("evaluation is not symmetric: %d vs %d", w, bl)
}
}
func TestMaterialCounting(t *testing.T) {
if got := Material(chess.NewBoard()); got != 0 {
t.Errorf("starting material = %d, want 0", got)
}
// White has an extra queen.
b, _ := chess.ParseFEN("4k3/8/8/8/8/8/8/3QK3 w - - 0 1")
if got := Material(b); got != 900 {
t.Errorf("material = %d, want 900", got)
}
}
// The clearest possible test of search: a mate in one must be found.
// The assertion is on the *outcome* rather than a named move, because a
// position can have more than one mate and any of them is correct.
func TestFindsMateInOne(t *testing.T) {
tests := []struct {
name string
fen string
}{
{"back rank", "6k1/5ppp/8/8/8/8/8/R3K3 w - - 0 1"},
{"queen mate", "7k/8/6K1/8/8/8/8/7Q w - - 0 1"},
{"rook and king", "7k/8/6K1/8/8/8/8/7R w - - 0 1"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
b, err := chess.ParseFEN(tt.fen)
if err != nil {
t.Fatal(err)
}
res := New().Search(context.Background(), b, Options{MaxDepth: 3, MaxTime: 20 * time.Second})
if res.Score < MateScore-100 {
t.Errorf("score %d does not indicate a forced mate", res.Score)
}
next, err := b.MakeMove(res.Best)
if err != nil {
t.Fatalf("returned an illegal move %s: %v", res.Best, err)
}
if next.Outcome() != chess.WhiteWins {
t.Errorf("%s did not deliver mate; position is %v\n%s", res.Best, next.Outcome(), next)
}
})
}
}
// A mate found must actually be mate on the board, not just a high score.
func TestMateScoreIsRealMate(t *testing.T) {
b, _ := chess.ParseFEN("6k1/5ppp/8/8/8/8/8/R3K3 w - - 0 1")
res := New().Search(context.Background(), b, Options{MaxDepth: 3})
next, err := b.MakeMove(res.Best)
if err != nil {
t.Fatal(err)
}
if next.Outcome() != chess.WhiteWins {
t.Errorf("engine claimed mate but the position is %v after %s", next.Outcome(), res.Best)
}
}
// Free material must be taken. This is the most basic sanity check on
// search and evaluation working together.
func TestCapturesHangingPiece(t *testing.T) {
// Black queen on d5 is undefended; white's rook on d1 can take it.
b, _ := chess.ParseFEN("4k3/8/8/3q4/8/8/8/3RK3 w - - 0 1")
res := New().Search(context.Background(), b, Options{MaxDepth: 4, MaxTime: 10 * time.Second})
if res.Best.String() != "d1d5" {
t.Errorf("played %s, want d1d5 to win the queen (score %d)", res.Best, res.Score)
}
}
// Quiescence search exists for exactly this: the engine must not grab a
// defended piece when the recapture loses material.
func TestDoesNotTakeDefendedPiece(t *testing.T) {
// White queen on d1, black pawn on d5 defended by a pawn on c6 and e6.
// Qxd5 loses the queen for a pawn.
b, _ := chess.ParseFEN("4k3/8/2p1p3/3p4/8/8/8/3QK3 w - - 0 1")
res := New().Search(context.Background(), b, Options{MaxDepth: 4, MaxTime: 10 * time.Second})
if res.Best.String() == "d1d5" {
t.Errorf("engine took a defended pawn with its queen (score %d)", res.Score)
}
}
// A stalemate must be scored as a draw, not as a loss or a win.
func TestStalemateScoredAsDraw(t *testing.T) {
b, _ := chess.ParseFEN("7k/5Q2/6K1/8/8/8/8/8 b - - 0 1")
res := New().Search(context.Background(), b, Options{MaxDepth: 2})
if res.Depth == 0 && res.Best == (chess.Move{}) {
return // no legal moves, which is the correct observation
}
if len(b.GenerateMoves()) != 0 {
t.Fatal("test position is not actually stalemate")
}
}
// Every move the engine returns must be legal in the position it was given.
func TestReturnedMoveIsAlwaysLegal(t *testing.T) {
fens := []string{
chess.StartingFEN,
"r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1",
"8/2p5/3p4/KP5r/1R3p1k/8/4P1P1/8 w - - 0 1",
"r4rk1/1pp1qppp/p1np1n2/2b1p1B1/2B1P1b1/P1NP1N2/1PP1QPPP/R4RK1 w - - 0 10",
}
for _, fen := range fens {
t.Run(fen, func(t *testing.T) {
b, err := chess.ParseFEN(fen)
if err != nil {
t.Fatal(err)
}
res := New().Search(context.Background(), b, Options{MaxDepth: 3, MaxTime: 10 * time.Second})
if _, err := b.MakeMove(res.Best); err != nil {
t.Errorf("engine returned an illegal move %s: %v", res.Best, err)
}
})
}
}
// Deeper search must not make the engine worse at a tactic it already sees.
func TestDeeperSearchStillFindsMate(t *testing.T) {
for depth := 1; depth <= 4; depth++ {
res := search(t, "6k1/5ppp/8/8/8/8/8/R3K3 w - - 0 1", depth)
if res.Best.String() != "a1a8" {
t.Errorf("depth %d played %s, want a1a8", depth, res.Best)
}
}
}
// The principal variation must be a playable sequence, not an arbitrary
// list of moves.
func TestPrincipalVariationIsPlayable(t *testing.T) {
b, _ := chess.ParseFEN("r4rk1/1pp1qppp/p1np1n2/2b1p1B1/2B1P1b1/P1NP1N2/1PP1QPPP/R4RK1 w - - 0 10")
res := New().Search(context.Background(), b, Options{MaxDepth: 4, MaxTime: 15 * time.Second})
if len(res.PV) == 0 {
t.Fatal("search returned an empty principal variation")
}
cur := b
for i, m := range res.PV {
next, err := cur.MakeMove(m)
if err != nil {
t.Fatalf("PV move %d (%s) is illegal: %v", i, m, err)
}
cur = next
}
}
// A time limit must actually stop the search.
func TestRespectsTimeLimit(t *testing.T) {
b := chess.NewBoard()
start := time.Now()
res := New().Search(context.Background(), b, Options{MaxDepth: 99, MaxTime: 200 * time.Millisecond})
elapsed := time.Since(start)
if elapsed > 3*time.Second {
t.Errorf("search ran for %v despite a 200ms limit", elapsed)
}
// It must still return something usable.
if _, err := b.MakeMove(res.Best); err != nil {
t.Errorf("search under a time limit returned an illegal move: %v", err)
}
}
func TestRespectsContextCancellation(t *testing.T) {
b := chess.NewBoard()
ctx, cancel := context.WithCancel(context.Background())
cancel()
res := New().Search(ctx, b, Options{MaxDepth: 99})
// Even cancelled immediately, a legal move must come back.
if _, err := b.MakeMove(res.Best); err != nil {
t.Errorf("cancelled search returned an illegal move %s: %v", res.Best, err)
}
}
// With no legal moves there is nothing to return, and the search must not
// panic trying.
func TestSearchWithNoLegalMoves(t *testing.T) {
b, _ := chess.ParseFEN("7k/5Q2/6K1/8/8/8/8/8 b - - 0 1")
res := New().Search(context.Background(), b, Options{MaxDepth: 3})
if res.Depth != 0 {
t.Errorf("depth = %d, want 0 when there are no moves", res.Depth)
}
}
// Alpha-beta must prune: a searched-with-ordering tree should visit far
// fewer nodes than the full width of the tree.
func TestSearchPrunes(t *testing.T) {
b := chess.NewBoard()
e := New()
e.Search(context.Background(), b, Options{MaxDepth: 4, MaxTime: 30 * time.Second})
// Perft(4) from the start is 197,281 positions. With alpha-beta and
// move ordering the search should see a small fraction of that.
if e.Nodes() > 100_000 {
t.Errorf("visited %d nodes at depth 4; pruning is not working", e.Nodes())
}
if e.Nodes() == 0 {
t.Error("visited no nodes")
}
}
func TestIsEndgameDetection(t *testing.T) {
if isEndgame(chess.NewBoard()) {
t.Error("the starting position is not an endgame")
}
b, _ := chess.ParseFEN("4k3/8/8/8/8/8/8/4K3 w - - 0 1")
if !isEndgame(b) {
t.Error("king versus king is an endgame")
}
}
func TestMirror(t *testing.T) {
// a1 (0) mirrors to a8 (56); e2 (12) mirrors to e7 (52).
if got := mirror(0); got != 56 {
t.Errorf("mirror(a1) = %d, want 56", got)
}
if got := mirror(12); got != 52 {
t.Errorf("mirror(e2) = %d, want 52", got)
}
// Mirroring twice is the identity.
for sq := range 64 {
if mirror(mirror(sq)) != sq {
t.Fatalf("mirror is not an involution at %d", sq)
}
}
}
func BenchmarkSearchDepth4(b *testing.B) {
board := chess.NewBoard()
bench := New()
b.ResetTimer()
for b.Loop() {
bench.Search(context.Background(), board, Options{MaxDepth: 4})
}
}
func BenchmarkEvaluate(b *testing.B) {
board := chess.NewBoard()
b.ResetTimer()
for b.Loop() {
Evaluate(board)
}
}