From 5b4dc7468191b03d377331d5fb4c66b2cbae6846 Mon Sep 17 00:00:00 2001 From: Josh Date: Sun, 17 Aug 2025 17:23:42 -0400 Subject: [PATCH] Update test suite Put all ffi code into its own module for clarity. --- test/base.py | 92 +++++-------------- test/chess_ffi.py | 158 ++++++++++++++++++++++++++++++++ test/test_fen_loader.py | 108 ++++++++++------------ test/test_movegen.py | 10 ++ test/test_piece_attack_cache.py | 8 +- 5 files changed, 242 insertions(+), 134 deletions(-) create mode 100644 test/chess_ffi.py diff --git a/test/base.py b/test/base.py index 5162eca..2d33202 100644 --- a/test/base.py +++ b/test/base.py @@ -1,79 +1,33 @@ -import ctypes -import platform import unittest -from pathlib import Path - - -WHITE, BLACK = 0, 1 -FILES = {c:i for i,c in enumerate("abcdefgh")} - - -def sq(name: str) -> int: - f = FILES[name[0].lower()] - r = int(name[1]) - 1 - return f + 8 * r - - -def bb_from(*algebraic): - m = 0 - for s in algebraic: - m |= (1 << sq(s)) - return m - - -def popcount(x: int) -> int: - return x.bit_count() - - -def draw_bb(mask: int, origin: int | None = None) -> str: - print("\n") - lines = [] - for r in range(7, -1, -1): - row = [] - for f in range(8): - sqi = r * 8 + f - bit = (mask >> sqi) & 1 - if origin is not None and sqi == origin: - ch = 'O' - elif bit: - ch = 'x' - else: - ch = '.' - row.append(ch) - lines.append(f"{r+1} " + " ".join(row)) - lines.append(" " + " ".join(FILES)) - lines = "\n".join(lines) - print(lines, "\n") +from test.chess_ffi import Board +from test.chess_ffi import KING_ATTACKS +from test.chess_ffi import KNIGHT_ATTACKS +from test.chess_ffi import PAWN_ATTACKS +from test.chess_ffi import gen_moves +from test.chess_ffi import init_attack_caches +from test.chess_ffi import load_fen class ChessLibTestBase(unittest.TestCase): @classmethod def setUpClass(cls): - root = Path(__file__).resolve().parents[1] - libname = "libchess.so" - libpath = root / "build" / libname - cls.lib = ctypes.CDLL(str(libpath)) + init_attack_caches() - attack_cache_functions = [ - "create_knight_attack_cache", - "create_pawn_attack_cache", - "create_king_attack_cache", - ] + cls.KNIGHT_ATTACKS = KNIGHT_ATTACKS + cls.KING_ATTACKS = KING_ATTACKS + cls.PAWN_ATTACKS = PAWN_ATTACKS - # init functions - for fn in attack_cache_functions: - getattr(cls.lib, fn).argtypes = [] - getattr(cls.lib, fn).restype = None - - cls.lib.create_knight_attack_cache() - cls.lib.create_pawn_attack_cache() - cls.lib.create_king_attack_cache() - KnightArr = ctypes.c_uint64 * 64 - KingArr = ctypes.c_uint64 * 64 - PawnRow = ctypes.c_uint64 * 64 - PawnArr = PawnRow * 2 + def setUp(self): + # This should be an empty board for each test in the suite. + self.board = Board() - cls.KNIGHT_ATTACKS = KnightArr.in_dll(cls.lib, "KNIGHT_ATTACKS") - cls.KING_ATTACKS = KingArr.in_dll(cls.lib, "KING_ATTACKS") - cls.PAWN_ATTACKS = PawnArr.in_dll(cls.lib, "PAWN_ATTACKS") \ No newline at end of file + + def load(self, fen: str): + rc = load_fen(self.board, fen) + self.assertEqual(rc, 0, f"load_fen failed rc={rc} for FEN: {fen}") + return self.board + + + def gen(self, captures_only: bool = False, cap: int = 256): + return gen_moves(self.board, captures_only=captures_only, cap=cap) \ No newline at end of file diff --git a/test/chess_ffi.py b/test/chess_ffi.py new file mode 100644 index 0000000..b0abc45 --- /dev/null +++ b/test/chess_ffi.py @@ -0,0 +1,158 @@ +""" + FFI - Foreign Function Interface + + This module needs to reflect the function interfaces that are + defined in our C modules. This may or may not be a good way to + test C since we essentially need to maintain two sets of interfaces. +""" +import ctypes as C + + +def _lib_path(): + # Just use a relative path from makefile. + return "./build/libchess.so" + + +_lib = C.CDLL(str(_lib_path())) + + +FILES = {c:i for i,c in enumerate("abcdefgh")} +WHITE, BLACK, BOTH = 0, 1, 2 +P, N, B, R, Q, K, p, n, b, r, q, k = range(12) + + +class Board(C.Structure): + _fields_ = [ + ("pieces", C.c_uint64 * 12), + ("occ", C.c_uint64 * 3), + ("king_square", C.c_uint64 * 2), + ("castling_rights", C.c_uint8), + ("ep_square", C.c_int), + ("side_to_move", C.c_int), + ("halfmove_clock", C.c_int), + ("fullmove_number", C.c_int), + ] + + +class Move(C.Structure): + _fields_ = [ + ("from", C.c_uint16), + ("to", C.c_uint16), + ("piece", C.c_uint8), + ("promo", C.c_uint8), + ("flags", C.c_uint8), + ] + + +_lib.load_fen.argtypes = (C.POINTER(Board), C.c_char_p) +_lib.load_fen.restype = C.c_int + +_lib.gen_pseudo_moves.argtypes = (C.POINTER(Board), C.POINTER(Move), C.c_bool) +_lib.gen_pseudo_moves.restype = C.c_int + + +def _bind_opt(name, argtypes=(), restype=None): + fn = getattr(_lib, name, None) + if fn is not None: + fn.argtypes = argtypes + fn.restype = restype + return fn + + +create_knight_attack_cache = _bind_opt("create_knight_attack_cache", (), None) +create_king_attack_cache = _bind_opt("create_king_attack_cache", (), None) +create_pawn_attack_cache = _bind_opt("create_pawn_attack_cache", (), None) + + +# PAWN move generation. +PAWN_SIG = (C.POINTER(Board), C.POINTER(Move), C.POINTER(C.c_int)), None + +gen_white_pawn_quiet_pushes = _bind_opt("gen_white_pawn_quiet_pushes", *PAWN_SIG) +gen_black_pawn_quiet_pushes = _bind_opt("gen_black_pawn_quiet_pushes", *PAWN_SIG) +gen_white_pawn_push_promotions = _bind_opt("gen_white_pawn_push_promotions", *PAWN_SIG) +gen_black_pawn_push_promotions = _bind_opt("gen_black_pawn_push_promotions", *PAWN_SIG) +gen_white_pawn_captures = _bind_opt("gen_white_pawn_captures", *PAWN_SIG) +gen_black_pawn_captures = _bind_opt("gen_black_pawn_captures", *PAWN_SIG) +gen_white_pawn_capture_promotions = _bind_opt("gen_white_pawn_capture_promotions", *PAWN_SIG) +gen_black_pawn_capture_promotions = _bind_opt("gen_black_pawn_capture_promotions", *PAWN_SIG) + + +# Non pawn move generation. +PIECE_SIG = ((C.POINTER(Board), C.POINTER(Move), C.POINTER(C.c_int), C.c_bool), None) + +gen_knight_moves = _bind_opt("gen_knight_moves", *PIECE_SIG) +gen_bishop_moves = _bind_opt("gen_bishop_moves", *PIECE_SIG) +gen_rook_moves = _bind_opt("gen_rook_moves", *PIECE_SIG) +gen_queen_moves = _bind_opt("gen_queen_moves", *PIECE_SIG) +gen_king_moves = _bind_opt("gen_king_moves", *PIECE_SIG) + + +# Attack cache tables. +KnightArr = (C.c_uint64 * 64) +KingArr = (C.c_uint64 * 64) +PawnRow = (C.c_uint64 * 64) +PawnArr = PawnRow * 2 +try: + KNIGHT_ATTACKS = KnightArr.in_dll(_lib, "KNIGHT_ATTACKS") + KING_ATTACKS = KingArr.in_dll(_lib, "KING_ATTACKS") + PAWN_ATTACKS = PawnArr.in_dll(_lib, "PAWN_ATTACKS") +except ValueError: + KNIGHT_ATTACKS = KING_ATTACKS = PAWN_ATTACKS = None # symbols not exported + + +def init_attack_caches(): + if create_knight_attack_cache: create_knight_attack_cache() + if create_king_attack_cache: create_king_attack_cache() + if create_pawn_attack_cache: create_pawn_attack_cache() + + +def load_fen(board, fen): + return _lib.load_fen(C.byref(board), fen.encode("ascii")) + + +def gen_moves(board, captures_only=False, cap=256): + buf = (Move * cap)() + n = _lib.gen_pseudo_moves(C.byref(board), buf, captures_only) + return buf, n + + +def sq_to_coord(sq): + return chr(ord('a') + (sq % 8)) + chr(ord('1') + (sq // 8)) + + +def sq(name): + f = FILES[name[0].lower()] + r = int(name[1]) - 1 + return f + 8 * r + + +def bb_from(*algebraic): + m = 0 + for s in algebraic: + m |= (1 << sq(s)) + return m + + +def popcount(x: int) -> int: + return x.bit_count() + + +def draw_bb(mask: int, origin: int | None = None) -> str: + print("\n") + lines = [] + for r in range(7, -1, -1): + row = [] + for f in range(8): + sqi = r * 8 + f + bit = (mask >> sqi) & 1 + if origin is not None and sqi == origin: + ch = 'O' + elif bit: + ch = 'x' + else: + ch = '.' + row.append(ch) + lines.append(f"{r+1} " + " ".join(row)) + lines.append(" " + " ".join(FILES)) + lines = "\n".join(lines) + print(lines, "\n") \ No newline at end of file diff --git a/test/test_fen_loader.py b/test/test_fen_loader.py index 5bf3867..04a2758 100644 --- a/test/test_fen_loader.py +++ b/test/test_fen_loader.py @@ -1,83 +1,71 @@ -import ctypes from test.base import ChessLibTestBase -from test.base import BLACK -from test.base import WHITE -from test.base import sq -from test.base import popcount +from test.chess_ffi import Board +from test.chess_ffi import BLACK +from test.chess_ffi import WHITE +from test.chess_ffi import sq +from test.chess_ffi import popcount +from test.chess_ffi import load_fen -class Board(ctypes.Structure): - _fields_ = [ - ("pieces", ctypes.c_uint64 * 12), - ("occ", ctypes.c_uint64 * 3), - ("king_square", ctypes.c_uint64 * 2), - ("castling_rights", ctypes.c_uint8), - ("ep_square", ctypes.c_int), - ("side_to_move", ctypes.c_int), - ("halfmove_clock", ctypes.c_int), - ("fullmove_number", ctypes.c_int), - ] - - -class FenTests(ChessLibTestBase): - @classmethod - def setUpClass(cls): - super().setUpClass() - cls.lib.load_fen.argtypes = [ctypes.POINTER(Board), ctypes.c_char_p] - cls.lib.load_fen.restype = ctypes.c_int +class TestFenLoading(ChessLibTestBase): def rank_mask(self, r): return sum(1 << (r*8 + f) for f in range(8)) + + + def load_fen(self, fen, board=None): + if board: + return load_fen(board, fen) + return load_fen(self.board, fen) def test_startpos_fields_and_occupancies(self): fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1" - b = Board() + rc = self.load_fen(fen) - self.lib.load_fen(ctypes.byref(b), fen.encode("ascii")) + self.assertEqual(rc, 0) + self.assertEqual(self.board.side_to_move, WHITE) + self.assertEqual(self.board.castling_rights, 0b1111) + self.assertEqual(self.board.ep_square, -1) + self.assertEqual(self.board.halfmove_clock, 0) + self.assertEqual(self.board.fullmove_number, 1) - self.assertEqual(b.side_to_move, WHITE) - self.assertEqual(b.castling_rights, 0b1111) # KQkq - self.assertEqual(b.ep_square, -1) - self.assertEqual(b.halfmove_clock, 0) - self.assertEqual(b.fullmove_number, 1) + white_expected = self.rank_mask(0) | self.rank_mask(1) + black_expected = self.rank_mask(6) | self.rank_mask(7) + self.assertEqual(int(self.board.occ[WHITE]), white_expected) + self.assertEqual(int(self.board.occ[BLACK]), black_expected) + self.assertEqual(int(self.board.occ[2]), white_expected | black_expected) - white_expected = self.rank_mask(0) | self.rank_mask(1) # ranks 1 & 2 - black_expected = self.rank_mask(6) | self.rank_mask(7) # ranks 7 & 8 - self.assertEqual(int(b.occ[WHITE]), white_expected) - self.assertEqual(int(b.occ[BLACK]), black_expected) - self.assertEqual(int(b.occ[2]), white_expected | black_expected) - - self.assertEqual(popcount(int(b.occ[WHITE])), 16) - self.assertEqual(popcount(int(b.occ[BLACK])), 16) + self.assertEqual(popcount(int(self.board.occ[WHITE])), 16) + self.assertEqual(popcount(int(self.board.occ[BLACK])), 16) def test_castling_flags_parsing(self): fen = "r3k2r/8/8/8/8/8/8/R3K2R b KQkq - 5 42" - b = Board() - self.lib.load_fen(ctypes.byref(b), fen.encode("ascii")) - self.assertEqual(b.side_to_move, BLACK) - self.assertEqual(b.castling_rights, 0b1111) - self.assertEqual(b.halfmove_clock, 5) - self.assertEqual(b.fullmove_number, 42) + + rc = self.load_fen(fen) + self.assertEqual(self.board.side_to_move, BLACK) + self.assertEqual(self.board.castling_rights, 0b1111) + self.assertEqual(self.board.halfmove_clock, 5) + self.assertEqual(self.board.fullmove_number, 42) def test_en_passant_targets(self): # EP at e6, black to move - fen1 = "8/8/8/3pP3/8/8/8/8 b KQkq e6 0 1" - - b1 = Board() - self.lib.load_fen(ctypes.byref(b1), fen1.encode("ascii")) - self.assertEqual(b1.side_to_move, BLACK) - self.assertEqual(b1.ep_square, sq("e6")) + fen = "8/8/8/3pP3/8/8/8/8 b KQkq e6 0 1" + rc = self.load_fen(fen) + self.assertEqual(rc, 0) + self.assertEqual(self.board.side_to_move, BLACK) + self.assertEqual(self.board.ep_square, sq("e6")) - # EP at d3, white to move - fen2 = "8/8/8/8/3Pp3/8/8/8 w KQkq d3 12 7" - b2 = Board() - self.lib.load_fen(ctypes.byref(b2), fen2.encode("ascii")) - self.assertEqual(b2.side_to_move, WHITE) - self.assertEqual(b2.ep_square, sq("d3")) + # EP at d3, white to move — use a fresh Board + fen = "8/8/8/8/3Pp3/8/8/8 w KQkq d3 12 7" + b = Board() + rc = self.load_fen(fen, board=b) + self.assertEqual(rc, 0) + self.assertEqual(b.side_to_move, WHITE) + self.assertEqual(b.ep_square, sq("d3")) def test_malformed_piece_field(self): @@ -88,8 +76,7 @@ class FenTests(ChessLibTestBase): "8/8/8/8/8/8/8/8w - - 0 1", ] for fen in bad: - b = Board() - r = self.lib.load_fen(ctypes.byref(b), fen.encode("ascii")) + r = self.load_fen(fen, board=Board()) self.assertEqual(r, -1) @@ -101,6 +88,5 @@ class FenTests(ChessLibTestBase): "8/8/8/8/8/8/8/8 w - e4 0 1", # EP not rank 3/6 (your code allows any 1..8; consider tightening) ] for fen in bad: - b = Board() - r = self.lib.load_fen(ctypes.byref(b), fen.encode("ascii")) + r = self.load_fen(fen, board=Board()) self.assertEqual(r, -1) \ No newline at end of file diff --git a/test/test_movegen.py b/test/test_movegen.py index e69de29..6a71234 100644 --- a/test/test_movegen.py +++ b/test/test_movegen.py @@ -0,0 +1,10 @@ +import ctypes +from test.base import ChessLibTestBase + + + + +class TestMoveGeneration(ChessLibTestBase): + @classmethod + def setUpClass(cls): + pass diff --git a/test/test_piece_attack_cache.py b/test/test_piece_attack_cache.py index 0611506..25ad432 100644 --- a/test/test_piece_attack_cache.py +++ b/test/test_piece_attack_cache.py @@ -1,8 +1,8 @@ from test.base import ChessLibTestBase -from test.base import bb_from -from test.base import draw_bb -from test.base import sq -from test.base import BLACK, WHITE +from test.chess_ffi import bb_from +from test.chess_ffi import draw_bb +from test.chess_ffi import sq +from test.chess_ffi import BLACK, WHITE class KnightFixedCases(ChessLibTestBase):