minichess_test.gno
6.98 Kb · 249 lines
1package minichess
2
3import "testing"
4
5func perft(p Position, depth int) int {
6 if depth == 0 {
7 return 1
8 }
9 if depth == 1 {
10 return len(p.LegalMoves())
11 }
12 n := 0
13 for _, m := range p.LegalMoves() {
14 n += perft(p.Play(m), depth-1)
15 }
16 return n
17}
18
19func mustFEN(t *testing.T, fen string) Position {
20 t.Helper()
21 p, err := ParseFEN(fen)
22 if err != nil {
23 t.Fatalf("ParseFEN(%q): %v", fen, err)
24 }
25 return p
26}
27
28func mustMove(t *testing.T, s string) Move {
29 t.Helper()
30 m, err := ParseMove(s)
31 if err != nil {
32 t.Fatalf("ParseMove(%q): %v", s, err)
33 }
34 return m
35}
36
37func isLegal(t *testing.T, p Position, s string) bool {
38 t.Helper()
39 _, ok := p.FindLegal(mustMove(t, s))
40 return ok
41}
42
43func TestStartPosition(t *testing.T) {
44 p := NewGame()
45 if got := p.FEN(); got != StartFEN {
46 t.Fatalf("FEN round trip: got %q", got)
47 }
48 if p.PieceAt("e1") != King || p.PieceAt("d8") != -Queen {
49 t.Fatalf("pieces misplaced")
50 }
51}
52
53// Perft values cross-checked against python-chess. These positions cover
54// castling, en passant, promotions, pins and discovered checks.
55func TestPerft(t *testing.T) {
56 cases := []struct {
57 fen string
58 want []int
59 }{
60 {StartFEN, []int{20, 400, 8902}},
61 {"r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1", []int{48, 2039}},
62 {"8/2p5/3p4/KP5r/1R3p1k/8/4P1P1/8 w - - 0 1", []int{14, 191, 2812}},
63 {"r3k2r/Pppp1ppp/1b3nbN/nP6/BBP1P3/q4N2/Pp1P2PP/R2Q1RK1 w kq - 0 1", []int{6, 264, 9467}},
64 {"rnbq1k1r/pp1Pbppp/2p5/8/2B5/8/PPP1NnPP/RNBQK2R w KQ - 1 8", []int{44, 1486}},
65 }
66 for _, c := range cases {
67 p := mustFEN(t, c.fen)
68 for d, w := range c.want {
69 if got := perft(p, d+1); got != w {
70 t.Fatalf("%s: perft(%d) = %d, want %d", c.fen, d+1, got, w)
71 }
72 }
73 }
74}
75
76func TestParseMove(t *testing.T) {
77 m := mustMove(t, "E7E8N")
78 if m.Promo != Knight || m.String() != "e7e8n" {
79 t.Fatalf("got %v", m)
80 }
81 for _, bad := range []string{"", "e2", "i1i2", "a1a9", "e7e8k", "e2e4qq"} {
82 if _, err := ParseMove(bad); err == nil {
83 t.Fatalf("ParseMove(%q) should fail", bad)
84 }
85 }
86}
87
88func TestStartMoves(t *testing.T) {
89 p := NewGame()
90 for _, s := range []string{"e2e4", "e2e3", "g1f3", "b1a3"} {
91 if !isLegal(t, p, s) {
92 t.Fatalf("%s should be legal", s)
93 }
94 }
95 for _, s := range []string{"e2e5", "e1e2", "e1g1", "d1d3", "e7e5"} {
96 if isLegal(t, p, s) {
97 t.Fatalf("%s should be illegal", s)
98 }
99 }
100 n := p.Play(mustMove(t, "e2e4"))
101 if n.FEN() != "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1" {
102 t.Fatalf("after e2e4: %s", n.FEN())
103 }
104}
105
106func TestCastling(t *testing.T) {
107 p := mustFEN(t, "r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1")
108 n := p.Play(mustMove(t, "e1g1"))
109 if n.PieceAt("g1") != King || n.PieceAt("f1") != Rook || n.PieceAt("h1") != Empty {
110 t.Fatalf("king-side castle wrong: %s", n.FEN())
111 }
112 if n.Castling != CastleBK|CastleBQ {
113 t.Fatalf("white rights should be gone: %s", n.FEN())
114 }
115 n = p.Play(mustMove(t, "e1c1"))
116 if n.PieceAt("c1") != King || n.PieceAt("d1") != Rook || n.PieceAt("a1") != Empty {
117 t.Fatalf("queen-side castle wrong: %s", n.FEN())
118 }
119
120 // f1 attacked by the rook on f8: cannot castle through it.
121 p = mustFEN(t, "r3kr2/8/8/8/8/8/8/R3K2R w KQq - 0 1")
122 if isLegal(t, p, "e1g1") || !isLegal(t, p, "e1c1") {
123 t.Fatalf("castling through check")
124 }
125 // In check: no castling at all.
126 p = mustFEN(t, "4r1k1/8/8/8/8/8/8/R3K2R w KQ - 0 1")
127 if isLegal(t, p, "e1g1") || isLegal(t, p, "e1c1") {
128 t.Fatalf("castling out of check")
129 }
130 // Moving a rook loses that side's right.
131 p = mustFEN(t, "r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1")
132 n = p.Play(mustMove(t, "h1h2"))
133 if n.Castling != CastleWQ|CastleBK|CastleBQ {
134 t.Fatalf("rook move rights: %s", n.FEN())
135 }
136}
137
138func TestEnPassant(t *testing.T) {
139 p := mustFEN(t, "4k3/8/8/3pP3/8/8/8/4K3 w - d6 0 1")
140 if !isLegal(t, p, "e5d6") {
141 t.Fatalf("e5d6 en passant should be legal")
142 }
143 n := p.Play(mustMove(t, "e5d6"))
144 if n.PieceAt("d5") != Empty || n.PieceAt("d6") != Pawn {
145 t.Fatalf("en passant wrong: %s", n.FEN())
146 }
147 // Without the e.p. square the capture is not available.
148 p = mustFEN(t, "4k3/8/8/3pP3/8/8/8/4K3 w - - 0 1")
149 if isLegal(t, p, "e5d6") {
150 t.Fatalf("e5d6 should be illegal without e.p. right")
151 }
152}
153
154func TestPromotion(t *testing.T) {
155 p := mustFEN(t, "8/P6k/8/8/8/8/8/4K3 w - - 0 1")
156 lm, ok := p.FindLegal(mustMove(t, "a7a8"))
157 if !ok || lm.Promo != Queen || p.Play(lm).PieceAt("a8") != Queen {
158 t.Fatalf("a7a8 should promote to a queen")
159 }
160 lm, ok = p.FindLegal(mustMove(t, "a7a8n"))
161 if !ok || p.Play(lm).PieceAt("a8") != Knight {
162 t.Fatalf("under-promotion to knight failed")
163 }
164}
165
166func TestGameEnd(t *testing.T) {
167 // Fool's mate.
168 if s := mustFEN(t, "rnb1kbnr/pppp1ppp/8/4p3/6Pq/5P2/PPPPP2P/RNBQKBNR w KQkq - 1 3").Status(); s != Checkmate {
169 t.Fatalf("expected checkmate, got %s", s)
170 }
171 if s := mustFEN(t, "7k/5Q2/6K1/8/8/8/8/8 b - - 0 1").Status(); s != Stalemate {
172 t.Fatalf("expected stalemate, got %s", s)
173 }
174 if s := mustFEN(t, "7k/8/8/8/8/8/8/2B1K3 w - - 0 1").Status(); s != InsufficientMaterial {
175 t.Fatalf("K+B vs K should be a draw, got %s", s)
176 }
177 if s := mustFEN(t, "7k/8/8/8/8/8/8/R3K3 w - - 0 1").Status(); s != Ongoing {
178 t.Fatalf("K+R vs K is not a draw, got %s", s)
179 }
180 if s := mustFEN(t, "7k/8/8/8/8/8/8/R3K3 w - - 100 80").Status(); s != FiftyMoveDraw {
181 t.Fatalf("expected 50-move draw, got %s", s)
182 }
183 if NewGame().Status() != Ongoing {
184 t.Fatalf("start should be ongoing")
185 }
186}
187
188func TestBotFindsMateInOne(t *testing.T) {
189 p := mustFEN(t, "6k1/5ppp/8/8/8/8/8/R5K1 w - - 0 1") // Ra8#
190 for level := MinLevel; level <= MaxLevel; level++ {
191 m, ok := BestMove(p, level, 0)
192 if !ok || p.Play(m).Status() != Checkmate {
193 t.Fatalf("level %d played %s, not mate", level, m)
194 }
195 }
196}
197
198func TestBotTakesHangingQueen(t *testing.T) {
199 p := mustFEN(t, "4k3/8/8/3q4/4P3/8/8/4K3 w - - 0 1")
200 for level := MinLevel; level <= MaxLevel; level++ {
201 m, _ := BestMove(p, level, 0)
202 if m.String() != "e4d5" {
203 t.Fatalf("level %d played %s, want e4d5", level, m)
204 }
205 }
206}
207
208// The node budget is what bounds a bot reply's gas cost on-chain.
209func TestBotRespectsNodeBudget(t *testing.T) {
210 positions := []string{StartFEN, "r1bqkb1r/pppp1ppp/2n2n2/4p3/2B1P3/5N2/PPPP1PPP/RNBQK2R w KQkq - 4 4"}
211 for _, fen := range positions {
212 p := mustFEN(t, fen)
213 for level := MinLevel; level <= MaxLevel; level++ {
214 m, nodes, ok := search(p, level, 0)
215 if !ok {
216 t.Fatalf("no move for %s", fen)
217 }
218 if _, legal := p.FindLegal(m); !legal {
219 t.Fatalf("level %d played illegal %s", level, m)
220 }
221 if nodes > Level(level).Budget+1 {
222 t.Fatalf("level %d used %d nodes, budget %d", level, nodes, Level(level).Budget)
223 }
224 }
225 }
226}
227
228func TestBotIsDeterministic(t *testing.T) {
229 p := NewGame()
230 a, _ := BestMove(p, 2, 42)
231 b, _ := BestMove(p, 2, 42)
232 if a != b {
233 t.Fatalf("same inputs gave %s and %s", a, b)
234 }
235}
236
237func TestBotPlaysLegalGame(t *testing.T) {
238 p := NewGame()
239 for ply := 0; ply < 40 && p.Status() == Ongoing; ply++ {
240 m, ok := BestMove(p, 1, ply)
241 if !ok {
242 t.Fatalf("no move in ongoing position %s", p.FEN())
243 }
244 if _, legal := p.FindLegal(m); !legal {
245 t.Fatalf("bot played illegal %s in %s", m, p.FEN())
246 }
247 p = p.Play(m)
248 }
249}