From 87abdd576c11c4c266accebc5221def6777491b0 Mon Sep 17 00:00:00 2001 From: Alexandre Schmid Date: Fri, 12 Dec 2025 11:36:47 +0100 Subject: [PATCH 1/3] - Add unit tests for core game logic (board, game flow, reset, win/draw) - Add tests for AI models (RandomAI, MinimaxAI) - Introduce fuzz testing to ensure Minimax robustness - Improve overall code coverage to ~86% - Document test intent for clarity and maintainability --- ai_models/minimax_fuzz_test.go | 56 +++++++ ai_models/minimax_test.go | 40 +++++ ai_models/random_test.go | 37 +++++ coverage | 226 +++++++++++++++++++++++++++ game/board_test.go | 61 ++++++++ game/game_test.go | 268 +++++++++++++++++++++++++++++++++ screens/game_screen.go | 11 +- 7 files changed, 698 insertions(+), 1 deletion(-) create mode 100644 ai_models/minimax_fuzz_test.go create mode 100644 ai_models/minimax_test.go create mode 100644 ai_models/random_test.go create mode 100644 coverage create mode 100644 game/board_test.go create mode 100644 game/game_test.go diff --git a/ai_models/minimax_fuzz_test.go b/ai_models/minimax_fuzz_test.go new file mode 100644 index 0000000..fbe627f --- /dev/null +++ b/ai_models/minimax_fuzz_test.go @@ -0,0 +1,56 @@ +package ai_models + +import ( + "GoTicTacToe/game" + "testing" +) + +/* +FuzzMinimaxDoesNotCrash performs fuzz testing on the MinimaxAI algorithm. + +The goal of this test is NOT to verify the quality of the moves, +but to ensure that the Minimax implementation: +- never panics +- always terminates +- behaves safely for a range of board sizes + +The fuzzing engine generates random board sizes within a constrained range +to explore unexpected execution paths. +*/ +func FuzzMinimaxDoesNotCrash(f *testing.F) { + + // Seed corpus: start fuzzing with a valid board size + f.Add(3) + + // Fuzz function executed with randomly generated inputs + f.Fuzz(func(t *testing.T, size int) { + + // Ignore sizes that would produce invalid or unsupported boards + // This keeps the fuzzing focused on realistic game scenarios + if size < 3 || size > 5 { + return + } + + // Create a board with the fuzz-generated size + b := game.NewBoard(size, size) + + // Create AI player and opponent + p1 := &game.Player{Name: "AI"} + p2 := &game.Player{Name: "Human"} + + // Players array required by the AI interface + players := [2]*game.Player{p1, p2} + + // Instantiate the Minimax AI model + ai := MinimaxAI{} + + // Ask the AI to compute a move + x, y := ai.NextMove(b, p1, players) + + // The AI should never return absurd values or crash + // Even in edge cases, coordinates must remain within safe bounds + if x < -1 || y < -1 { + t.Fatalf("invalid move") + } + }) +} diff --git a/ai_models/minimax_test.go b/ai_models/minimax_test.go new file mode 100644 index 0000000..52a5512 --- /dev/null +++ b/ai_models/minimax_test.go @@ -0,0 +1,40 @@ +package ai_models + +import ( + "GoTicTacToe/game" + "testing" +) + +/* +TestMinimaxReturnsValidMove verifies that the MinimaxAI model +always returns a valid board coordinate on an empty board. + +This test does NOT attempt to validate the optimality of the move +(minimax correctness), which would require complex scenario-based tests. +Instead, it ensures that: +- the algorithm terminates +- no panic occurs +- the returned move is within board bounds +*/ +func TestMinimaxReturnsValidMove(t *testing.T) { + // Create a standard empty 3x3 board + b := game.NewBoard(3, 3) + + // Create players: one controlled by the AI, one opponent + p1 := &game.Player{Name: "AI"} + p2 := &game.Player{Name: "Human"} + + // Players array required by the AI interface + players := [2]*game.Player{p1, p2} + + // Instantiate the Minimax AI model + ai := MinimaxAI{} + + // Ask the AI to compute the next move + x, y := ai.NextMove(b, p1, players) + + // The returned move must be inside the board boundaries + if x < 0 || x >= 3 || y < 0 || y >= 3 { + t.Errorf("invalid move returned: %d,%d", x, y) + } +} diff --git a/ai_models/random_test.go b/ai_models/random_test.go new file mode 100644 index 0000000..fb986a6 --- /dev/null +++ b/ai_models/random_test.go @@ -0,0 +1,37 @@ +package ai_models + +import ( + "GoTicTacToe/game" + "testing" +) + +/* +TestRandomAIValidMove verifies that the RandomAI model always returns +a valid board coordinate when moves are available. + +The goal of this test is NOT to verify randomness or strategy, +but to ensure that: +- the AI does not crash +- the AI does not return invalid coordinates +*/ +func TestRandomAIValidMove(t *testing.T) { + // Create a standard empty 3x3 board + b := game.NewBoard(3, 3) + + // Create a dummy player controlled by the AI + p := &game.Player{} + + // Create the players array required by the AI interface + players := [2]*game.Player{p, {}} + + // Instantiate the RandomAI model + ai := RandomAI{} + + // Ask the AI to choose a move + x, y := ai.NextMove(b, p, players) + + // The returned coordinates should be valid (non-negative) + if x < 0 || y < 0 { + t.Errorf("random AI returned invalid move") + } +} diff --git a/coverage b/coverage new file mode 100644 index 0000000..bd384c0 --- /dev/null +++ b/coverage @@ -0,0 +1,226 @@ +mode: set +GoTicTacToe/main.go:15.13,23.45 6 0 +GoTicTacToe/main.go:23.45,25.3 1 0 +GoTicTacToe/game/board.go:15.39,22.25 2 1 +GoTicTacToe/game/board.go:22.25,24.3 1 1 +GoTicTacToe/game/board.go:25.2,25.10 1 1 +GoTicTacToe/game/board.go:30.48,32.50 1 1 +GoTicTacToe/game/board.go:32.50,34.3 1 0 +GoTicTacToe/game/board.go:36.2,36.26 1 1 +GoTicTacToe/game/board.go:36.26,38.3 1 1 +GoTicTacToe/game/board.go:40.2,41.13 2 1 +GoTicTacToe/game/board.go:46.36,50.25 2 1 +GoTicTacToe/game/board.go:50.25,53.38 1 1 +GoTicTacToe/game/board.go:53.38,55.4 1 0 +GoTicTacToe/game/board.go:58.3,59.26 2 1 +GoTicTacToe/game/board.go:59.26,61.4 1 1 +GoTicTacToe/game/board.go:62.3,62.31 1 1 +GoTicTacToe/game/board.go:62.31,64.4 1 1 +GoTicTacToe/game/board.go:68.2,69.25 2 1 +GoTicTacToe/game/board.go:69.25,71.3 1 1 +GoTicTacToe/game/board.go:72.2,72.32 1 1 +GoTicTacToe/game/board.go:72.32,74.3 1 0 +GoTicTacToe/game/board.go:77.2,78.25 2 1 +GoTicTacToe/game/board.go:78.25,80.3 1 1 +GoTicTacToe/game/board.go:81.2,81.32 1 1 +GoTicTacToe/game/board.go:81.32,83.3 1 0 +GoTicTacToe/game/board.go:85.2,85.12 1 1 +GoTicTacToe/game/board.go:91.35,93.18 2 1 +GoTicTacToe/game/board.go:93.18,95.3 1 1 +GoTicTacToe/game/board.go:96.2,96.25 1 1 +GoTicTacToe/game/board.go:96.25,97.17 1 1 +GoTicTacToe/game/board.go:97.17,99.4 1 1 +GoTicTacToe/game/board.go:101.2,101.14 1 1 +GoTicTacToe/game/board.go:105.34,106.25 1 1 +GoTicTacToe/game/board.go:106.25,107.29 1 1 +GoTicTacToe/game/board.go:107.29,108.28 1 1 +GoTicTacToe/game/board.go:108.28,110.5 1 1 +GoTicTacToe/game/board.go:113.2,113.13 1 1 +GoTicTacToe/game/board.go:117.25,118.25 1 0 +GoTicTacToe/game/board.go:118.25,119.29 1 0 +GoTicTacToe/game/board.go:119.29,121.4 1 0 +GoTicTacToe/game/game.go:25.22,29.2 3 1 +GoTicTacToe/game/game.go:33.28,44.2 5 1 +GoTicTacToe/game/game.go:48.24,53.2 4 0 +GoTicTacToe/game/game.go:56.30,57.30 1 0 +GoTicTacToe/game/game.go:57.30,59.3 1 0 +GoTicTacToe/game/game.go:64.29,65.25 1 1 +GoTicTacToe/game/game.go:65.25,67.3 1 0 +GoTicTacToe/game/game.go:68.2,68.30 1 1 +GoTicTacToe/game/game.go:68.30,69.21 1 1 +GoTicTacToe/game/game.go:69.21,72.4 2 1 +GoTicTacToe/game/game.go:75.2,75.26 1 0 +GoTicTacToe/game/game.go:80.40,83.9 2 1 +GoTicTacToe/game/game.go:83.9,85.3 1 0 +GoTicTacToe/game/game.go:88.2,88.18 1 1 +GoTicTacToe/game/game.go:88.18,90.3 1 0 +GoTicTacToe/game/game.go:93.2,93.19 1 1 +GoTicTacToe/game/game.go:93.19,95.3 1 0 +GoTicTacToe/game/game.go:98.2,99.13 2 1 +GoTicTacToe/game/game.go:104.32,106.14 2 1 +GoTicTacToe/game/game.go:106.14,111.3 4 1 +GoTicTacToe/game/game.go:112.2,112.14 1 1 +GoTicTacToe/game/game.go:116.33,117.25 1 1 +GoTicTacToe/game/game.go:117.25,121.3 3 1 +GoTicTacToe/game/game.go:122.2,122.14 1 1 +GoTicTacToe/game/game.go:125.41,127.30 2 1 +GoTicTacToe/game/game.go:127.30,128.31 1 1 +GoTicTacToe/game/game.go:128.31,129.28 1 1 +GoTicTacToe/game/game.go:129.28,131.5 1 1 +GoTicTacToe/game/game.go:134.2,134.14 1 1 +GoTicTacToe/game/game.go:137.32,139.30 2 0 +GoTicTacToe/game/game.go:139.30,140.31 1 0 +GoTicTacToe/game/game.go:140.31,142.4 1 0 +GoTicTacToe/game/game.go:144.2,144.14 1 0 +GoTicTacToe/game/player.go:15.56,17.2 1 1 +GoTicTacToe/game/player.go:19.55,20.21 1 0 +GoTicTacToe/game/player.go:20.21,22.3 1 0 +GoTicTacToe/game/player.go:23.2,23.19 1 0 +GoTicTacToe/game/symbol.go:27.57,32.20 4 1 +GoTicTacToe/game/symbol.go:33.13,37.14 4 1 +GoTicTacToe/game/symbol.go:39.14,41.14 2 1 +GoTicTacToe/game/symbol.go:44.2,44.45 1 1 +GoTicTacToe/game/symbol.go:47.47,52.2 1 1 +GoTicTacToe/screens/ai_screen.go:17.58,28.11 2 0 +GoTicTacToe/screens/ai_screen.go:28.11,31.5 2 0 +GoTicTacToe/screens/ai_screen.go:37.11,40.5 2 0 +GoTicTacToe/screens/ai_screen.go:46.11,48.5 1 0 +GoTicTacToe/screens/ai_screen.go:52.2,52.10 1 0 +GoTicTacToe/screens/ai_screen.go:55.35,56.27 1 0 +GoTicTacToe/screens/ai_screen.go:56.27,58.3 1 0 +GoTicTacToe/screens/ai_screen.go:59.2,59.12 1 0 +GoTicTacToe/screens/ai_screen.go:62.47,63.27 1 0 +GoTicTacToe/screens/ai_screen.go:63.27,65.3 1 0 +GoTicTacToe/screens/game_screen.go:36.62,51.49 5 0 +GoTicTacToe/screens/game_screen.go:51.49,54.3 2 0 +GoTicTacToe/screens/game_screen.go:56.2,64.18 2 0 +GoTicTacToe/screens/game_screen.go:64.18,66.4 1 0 +GoTicTacToe/screens/game_screen.go:69.2,69.11 1 0 +GoTicTacToe/screens/game_screen.go:73.38,76.35 1 0 +GoTicTacToe/screens/game_screen.go:76.35,78.19 2 0 +GoTicTacToe/screens/game_screen.go:78.19,82.32 2 0 +GoTicTacToe/screens/game_screen.go:82.32,84.5 1 0 +GoTicTacToe/screens/game_screen.go:84.10,86.5 1 0 +GoTicTacToe/screens/game_screen.go:88.4,89.20 2 0 +GoTicTacToe/screens/game_screen.go:89.20,92.5 2 0 +GoTicTacToe/screens/game_screen.go:94.4,94.14 1 0 +GoTicTacToe/screens/game_screen.go:99.2,102.36 2 0 +GoTicTacToe/screens/game_screen.go:102.36,103.65 1 0 +GoTicTacToe/screens/game_screen.go:103.65,105.4 1 0 +GoTicTacToe/screens/game_screen.go:109.2,109.56 1 0 +GoTicTacToe/screens/game_screen.go:109.56,111.3 1 0 +GoTicTacToe/screens/game_screen.go:112.2,112.51 1 0 +GoTicTacToe/screens/game_screen.go:112.51,115.3 2 0 +GoTicTacToe/screens/game_screen.go:117.2,117.12 1 0 +GoTicTacToe/screens/game_screen.go:121.50,131.36 5 0 +GoTicTacToe/screens/game_screen.go:131.36,133.3 1 0 +GoTicTacToe/screens/game_screen.go:141.55,153.2 6 0 +GoTicTacToe/screens/game_screen.go:156.55,168.2 6 0 +GoTicTacToe/screens/game_screen.go:171.60,173.27 2 0 +GoTicTacToe/screens/game_screen.go:173.27,175.37 2 0 +GoTicTacToe/screens/game_screen.go:176.19,177.13 1 0 +GoTicTacToe/screens/game_screen.go:178.20,179.13 1 0 +GoTicTacToe/screens/game_screen.go:182.3,182.37 1 0 +GoTicTacToe/screens/game_screen.go:184.8,186.3 1 0 +GoTicTacToe/screens/game_screen.go:188.2,197.46 7 0 +GoTicTacToe/screens/screen.go:23.51,28.2 1 0 +GoTicTacToe/screens/screen.go:30.42,32.2 1 0 +GoTicTacToe/screens/screen.go:34.37,35.22 1 0 +GoTicTacToe/screens/screen.go:35.22,37.3 1 0 +GoTicTacToe/screens/screen.go:38.2,38.12 1 0 +GoTicTacToe/screens/screen.go:41.49,42.22 1 0 +GoTicTacToe/screens/screen.go:42.22,44.3 1 0 +GoTicTacToe/screens/screen.go:47.50,49.2 1 0 +GoTicTacToe/screens/start_screen.go:24.48,31.11 2 0 +GoTicTacToe/screens/start_screen.go:31.11,35.5 1 0 +GoTicTacToe/screens/start_screen.go:41.11,45.5 1 0 +GoTicTacToe/screens/start_screen.go:54.12,54.13 0 0 +GoTicTacToe/screens/start_screen.go:57.2,57.10 1 0 +GoTicTacToe/screens/start_screen.go:60.38,61.32 1 0 +GoTicTacToe/screens/start_screen.go:61.32,63.3 1 0 +GoTicTacToe/screens/start_screen.go:64.2,64.12 1 0 +GoTicTacToe/screens/start_screen.go:67.50,79.32 7 0 +GoTicTacToe/screens/start_screen.go:79.32,81.3 1 0 +GoTicTacToe/screens/utils.go:5.41,8.2 2 0 +GoTicTacToe/ui/utils/anchor.go:24.22,29.16 3 0 +GoTicTacToe/ui/utils/anchor.go:30.23,31.41 1 0 +GoTicTacToe/ui/utils/anchor.go:32.22,33.37 1 0 +GoTicTacToe/ui/utils/anchor.go:35.24,36.41 1 0 +GoTicTacToe/ui/utils/anchor.go:37.20,39.41 2 0 +GoTicTacToe/ui/utils/anchor.go:40.25,42.41 2 0 +GoTicTacToe/ui/utils/anchor.go:44.24,45.37 1 0 +GoTicTacToe/ui/utils/anchor.go:46.26,48.37 2 0 +GoTicTacToe/ui/utils/anchor.go:49.25,51.37 2 0 +GoTicTacToe/ui/utils/anchor.go:54.2,54.13 1 0 +GoTicTacToe/ui/utils/hover.go:18.36,20.2 1 0 +GoTicTacToe/ui/utils/hover.go:23.59,33.2 3 0 +GoTicTacToe/ui/utils/hover.go:36.85,37.25 1 0 +GoTicTacToe/ui/utils/hover.go:39.17,42.43 2 0 +GoTicTacToe/ui/utils/hover.go:44.22,47.36 2 0 +GoTicTacToe/ui/utils/hover.go:49.18,51.17 1 0 +GoTicTacToe/ui/utils/hover.go:51.17,53.4 1 0 +GoTicTacToe/ui/utils/hover.go:53.9,55.4 1 0 +GoTicTacToe/ui/utils/shape.go:12.92,21.2 5 0 +GoTicTacToe/ui/board.go:26.14,45.2 3 0 +GoTicTacToe/ui/board.go:49.53,60.41 6 0 +GoTicTacToe/ui/board.go:60.41,78.3 11 0 +GoTicTacToe/ui/board.go:80.2,80.12 1 0 +GoTicTacToe/ui/board.go:84.30,85.65 1 0 +GoTicTacToe/ui/board.go:85.65,91.52 3 0 +GoTicTacToe/ui/board.go:91.52,100.28 4 0 +GoTicTacToe/ui/board.go:100.28,102.5 1 0 +GoTicTacToe/ui/board.go:108.48,121.41 8 0 +GoTicTacToe/ui/board.go:121.41,122.42 1 0 +GoTicTacToe/ui/board.go:122.42,124.41 2 0 +GoTicTacToe/ui/board.go:124.41,125.13 1 0 +GoTicTacToe/ui/board.go:128.4,133.19 4 0 +GoTicTacToe/ui/board.go:133.19,135.5 1 0 +GoTicTacToe/ui/board.go:136.4,154.38 9 0 +GoTicTacToe/ui/button.go:30.11,52.2 2 0 +GoTicTacToe/ui/button.go:55.27,66.11 4 0 +GoTicTacToe/ui/button.go:66.11,68.18 2 0 +GoTicTacToe/ui/button.go:68.18,70.4 1 0 +GoTicTacToe/ui/button.go:71.8,73.18 2 0 +GoTicTacToe/ui/button.go:73.18,75.4 1 0 +GoTicTacToe/ui/button.go:79.2,79.74 1 0 +GoTicTacToe/ui/button.go:79.74,80.23 1 0 +GoTicTacToe/ui/button.go:80.23,82.4 1 0 +GoTicTacToe/ui/button.go:87.45,109.2 11 0 +GoTicTacToe/ui/score.go:20.92,34.2 2 0 +GoTicTacToe/ui/score.go:37.49,46.22 6 0 +GoTicTacToe/ui/score.go:46.22,48.3 1 0 +GoTicTacToe/ui/score.go:51.2,53.39 2 0 +GoTicTacToe/ui/score.go:53.39,56.3 2 0 +GoTicTacToe/ui/score.go:63.3,68.46 3 0 +GoTicTacToe/ui/score.go:68.46,73.12 4 0 +GoTicTacToe/ui/score.go:73.12,75.4 1 0 +GoTicTacToe/ui/score.go:76.3,87.66 6 0 +GoTicTacToe/ui/score.go:87.66,89.4 1 0 +GoTicTacToe/ui/score.go:91.3,91.39 1 0 +GoTicTacToe/ui/score.go:95.2,108.49 9 0 +GoTicTacToe/ui/widget.go:22.51,30.2 2 0 +GoTicTacToe/assets/fonts.go:16.13,18.16 2 0 +GoTicTacToe/assets/fonts.go:18.16,20.3 1 0 +GoTicTacToe/assets/fonts.go:22.2,30.3 2 0 +GoTicTacToe/ai_models/minimax.go:7.99,11.44 3 1 +GoTicTacToe/ai_models/minimax.go:11.44,17.24 4 1 +GoTicTacToe/ai_models/minimax.go:17.24,20.4 2 1 +GoTicTacToe/ai_models/minimax.go:23.2,23.31 1 1 +GoTicTacToe/ai_models/minimax.go:26.96,28.18 2 1 +GoTicTacToe/ai_models/minimax.go:28.18,30.3 1 1 +GoTicTacToe/ai_models/minimax.go:31.2,31.35 1 1 +GoTicTacToe/ai_models/minimax.go:31.35,33.3 1 1 +GoTicTacToe/ai_models/minimax.go:34.2,34.23 1 1 +GoTicTacToe/ai_models/minimax.go:34.23,36.3 1 1 +GoTicTacToe/ai_models/minimax.go:38.2,38.16 1 1 +GoTicTacToe/ai_models/minimax.go:38.16,40.45 2 1 +GoTicTacToe/ai_models/minimax.go:40.45,44.20 4 1 +GoTicTacToe/ai_models/minimax.go:44.20,46.5 1 1 +GoTicTacToe/ai_models/minimax.go:48.3,48.14 1 1 +GoTicTacToe/ai_models/minimax.go:52.2,55.44 3 1 +GoTicTacToe/ai_models/minimax.go:55.44,59.19 4 1 +GoTicTacToe/ai_models/minimax.go:59.19,61.4 1 1 +GoTicTacToe/ai_models/minimax.go:64.2,64.13 1 1 +GoTicTacToe/ai_models/random.go:10.98,12.21 2 1 +GoTicTacToe/ai_models/random.go:12.21,14.3 1 0 +GoTicTacToe/ai_models/random.go:16.2,17.17 2 1 diff --git a/game/board_test.go b/game/board_test.go new file mode 100644 index 0000000..7c9b658 --- /dev/null +++ b/game/board_test.go @@ -0,0 +1,61 @@ +package game + +import "testing" + +/* +TestBoardPlayAndAvailableMoves verifies the basic behavior of the Board: +- a valid move can be played +- the corresponding cell is updated +- the number of available moves decreases accordingly +*/ +func TestBoardPlayAndAvailableMoves(t *testing.T) { + // Create a standard 3x3 board where 3 aligned symbols are needed to win + b := NewBoard(3, 3) + + // Create a test player + p := &Player{Name: "P1"} + + // Play a valid move in the center of the board + ok := b.Play(p, 1, 1) + if !ok { + t.Fatalf("expected move to be valid") + } + + // The cell (1,1) should now contain the player pointer + if b.Cells[1][1] != p { + t.Errorf("cell not updated correctly") + } + + // After one move on a 3x3 board, 8 moves should remain available + moves := b.AvailableMoves() + if len(moves) != 8 { + t.Errorf("expected 8 available moves, got %d", len(moves)) + } +} + +//================================================================================== + +/* +TestBoardInvalidMove verifies that the Board correctly rejects invalid moves, +such as attempting to play on an already occupied cell. +*/ +func TestBoardInvalidMove(t *testing.T) { + // Create a new empty board + b := NewBoard(3, 3) + + // Create a test player + p := &Player{Name: "P1"} + + // Play a move in the top-left corner + b.Play(p, 0, 0) + + // Attempt to play in the same cell again + ok := b.Play(p, 0, 0) + + // The second move should be rejected + if ok { + t.Errorf("expected move to be rejected") + } +} + +//================================================================================== diff --git a/game/game_test.go b/game/game_test.go new file mode 100644 index 0000000..acf136d --- /dev/null +++ b/game/game_test.go @@ -0,0 +1,268 @@ +package game + +import "testing" + +/* +TestWinDetection verifies that a simple winning condition +(3 symbols aligned in a row) is correctly detected by the game. + +This test uses Board.Play directly to isolate win detection logic +without involving turn switching. +*/ +func TestWinDetection(t *testing.T) { + g := NewGame() + p := g.Players[0] + + // Player places three symbols in the top row + g.Board.Play(p, 0, 0) + g.Board.Play(p, 1, 0) + g.Board.Play(p, 2, 0) + + // The game should detect a win + if !g.CheckWin() { + t.Fatalf("expected win to be detected") + } + + // The winner should be the player who placed the symbols + if g.Winner != p { + t.Errorf("wrong winner detected") + } +} + +//================================================================================== + +/* +TestDrawDetection verifies that a full board with no winner +is correctly detected as a draw. + +The move sequence fills the board completely while alternating players, +ensuring no winning alignment exists. +*/ +func TestDrawDetection(t *testing.T) { + g := NewGame() + p1 := g.Players[0] + p2 := g.Players[1] + + // Sequence of moves that fills the board without any win + moves := []struct { + p *Player + x int + y int + }{ + {p1, 0, 0}, {p2, 1, 0}, {p1, 2, 0}, + {p1, 0, 1}, {p2, 1, 1}, {p1, 2, 1}, + {p2, 0, 2}, {p1, 1, 2}, {p2, 2, 2}, + } + + for _, m := range moves { + g.Board.Play(m.p, m.x, m.y) + } + + // The game should detect a draw + if !g.CheckDraw() { + t.Fatalf("expected draw to be detected") + } +} + +//================================================================================== + +/* +TestPlayMoveSwitchesPlayer ensures that calling PlayMove +correctly switches the current player after a valid move. (also ensures valid moves) +*/ +func TestPlayMoveSwitchesPlayer(t *testing.T) { + g := NewGame() + first := g.Current + + ok := g.PlayMove(0, 0) + if !ok { + t.Fatalf("valid move rejected") + } + + // The current player should have changed + if g.Current == first { + t.Errorf("expected current player to switch") + } +} + +//================================================================================== + +/* +TestResetHard verifies that ResetHard completely resets the game: +- board is cleared +- winner is cleared +- points are reset +- game state returns to PLAYING +*/ +func TestResetHard(t *testing.T) { + g := NewGame() + + // Modify the game state + g.Players[0].Points = 5 + g.Board.Play(g.Players[0], 0, 0) + + g.ResetHard() + + if g.State != PLAYING { + t.Errorf("expected state PLAYING after ResetHard") + } + + if g.Winner != nil { + t.Errorf("winner should be nil after ResetHard") + } + + if g.Players[0].Points != 0 { + t.Errorf("points should be reset") + } + + // All cells should be empty again + if len(g.Board.AvailableMoves()) != 9 { + t.Errorf("board should be empty after ResetHard") + } +} + +//================================================================================== + +/* +TestResetKeepsPoints ensures that Reset only clears the board +but does NOT reset player scores. +*/ +func TestResetKeepsPoints(t *testing.T) { + g := NewGame() + g.Players[0].Points = 3 + + g.Board.Play(g.Players[0], 0, 0) + g.Reset() + + if g.Players[0].Points != 3 { + t.Errorf("Reset should not reset points") + } + + if len(g.Board.AvailableMoves()) != 9 { + t.Errorf("board should be cleared on Reset") + } +} + +//================================================================================== + +/* +TestResetPoints verifies that ResetPoints correctly resets +the score of all players without modifying the board. +*/ +func TestResetPoints(t *testing.T) { + g := NewGame() + g.Players[0].Points = 2 + g.Players[1].Points = 4 + + g.ResetPoints() + + for i, p := range g.Players { + if p.Points != 0 { + t.Errorf("player %d points not reset", i) + } + } +} + +//================================================================================== + +/* +TestNextPlayerWrapsAround ensures that when the current player +is the last one in the list, NextPlayer wraps back to the first player. +*/ +func TestNextPlayerWrapsAround(t *testing.T) { + g := NewGame() + + g.Current = g.Players[1] + g.NextPlayer() + + if g.Current != g.Players[0] { + t.Errorf("NextPlayer should wrap to first player") + } +} + +//================================================================================== + +/* +TestNextPlayerFallback verifies a defensive behavior: +if Current is not found in the Players slice, +NextPlayer should safely reset it to the first player. +*/ +func TestNextPlayerFallback(t *testing.T) { + g := NewGame() + + // Assign an invalid current player + g.Current = &Player{Name: "ghost"} + + g.NextPlayer() + + if g.Current != g.Players[0] { + t.Errorf("fallback should reset current to first player") + } +} + +//================================================================================== + +/* +TestPlayMoveInvalidDoesNotSwitchPlayer ensures that an invalid move: +- is rejected +- does NOT switch the current player +- playing in the same spot is tested here +*/ +func TestPlayMoveInvalidDoesNotSwitchPlayer(t *testing.T) { + g := NewGame() + start := g.Current + + ok := g.PlayMove(0, 0) + if !ok { + t.Fatalf("first move should be valid") + } + + // Try to play in the same cell again + ok = g.PlayMove(0, 0) + if ok { + t.Errorf("move should be invalid") + } + + // Current player should not change + if g.Current == start { + t.Errorf("current player should not change on invalid move") + } +} + +//================================================================================== + +/* +TestDiagonalWinViaPlayMove tests a full game flow using PlayMove: +- players alternate turns +- a diagonal win is created +- the game state transitions to GAME_END +*/ +func TestDiagonalWinViaPlayMove(t *testing.T) { + g := NewGame() + p1 := g.Players[0] + + // Sequence of moves leading to a diagonal win for p1 + moves := []struct { + x, y int + }{ + {0, 0}, // p1 + {0, 1}, // p2 + {1, 1}, // p1 + {0, 2}, // p2 + {2, 2}, // p1 -> win + } + + for _, m := range moves { + g.PlayMove(m.x, m.y) + } + + if g.Winner != p1 { + t.Errorf("expected diagonal win for p1") + } + + if g.State != GAME_END { + t.Errorf("game should be ended after win") + } +} + +//================================================================================== diff --git a/screens/game_screen.go b/screens/game_screen.go index 2b99d7f..e935d39 100644 --- a/screens/game_screen.go +++ b/screens/game_screen.go @@ -133,7 +133,16 @@ func (gs *GameScreen) Draw(screen *ebiten.Image) { func (gs *GameScreen) drawEndMessage(screen *ebiten.Image) { var msg string if gs.game.Winner != nil { - msg = fmt.Sprintf("%s wins!", gs.game.Winner.Symbol.Type) + var sym string + switch gs.game.Winner.Symbol.Type { + case game.CROSS: + sym = "X" + case game.CIRCLE: + sym = "O" + } + + msg = fmt.Sprintf("%s wins!", sym) + } else { msg = "It's a draw!" } From 22e3d1bdd6e2e7f083f597169eb29f0765032223 Mon Sep 17 00:00:00 2001 From: Alexandre Schmid Date: Fri, 12 Dec 2025 11:48:40 +0100 Subject: [PATCH 2/3] Fixed symbols path (now assets before it was in game) --- screens/game_screen.go | 4 ++-- screens/screen.go | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/screens/game_screen.go b/screens/game_screen.go index e935d39..1400344 100644 --- a/screens/game_screen.go +++ b/screens/game_screen.go @@ -135,9 +135,9 @@ func (gs *GameScreen) drawEndMessage(screen *ebiten.Image) { if gs.game.Winner != nil { var sym string switch gs.game.Winner.Symbol.Type { - case game.CROSS: + case assets.CrossSymbol: sym = "X" - case game.CIRCLE: + case assets.CircleSymbol: sym = "O" } diff --git a/screens/screen.go b/screens/screen.go index fb4b0f1..2946f4e 100644 --- a/screens/screen.go +++ b/screens/screen.go @@ -21,8 +21,8 @@ type ScreenHost interface { } type screenHost struct { - current Screen - debugui debugui.DebugUI + current Screen + debugui debugui.DebugUI } func NewScreenHost() *screenHost { From d57a9cca633ea3fe531adf6553f16299567b9356 Mon Sep 17 00:00:00 2001 From: Alexandre Schmid Date: Fri, 9 Jan 2026 10:22:59 +0100 Subject: [PATCH 3/3] Fixed end of file precommit --- .github/workflows/ci.yml | 48 +++ .golangci.yml | 19 ++ .pre-commit-config.yaml | 27 ++ ai_models/ai.go | 14 +- ai_models/minimax.go | 130 ++++---- ai_models/minimax_fuzz_test.go | 112 +++---- ai_models/minimax_test.go | 80 ++--- ai_models/random.go | 36 +-- ai_models/random_test.go | 74 ++--- assets/images.go | 9 +- game/board.go | 6 +- game/board_test.go | 122 ++++---- game/game_test.go | 536 ++++++++++++++++----------------- game/moves.go | 12 +- screens/ai_screen.go | 132 ++++---- screens/game_config.go | 30 +- screens/start_screen.go | 10 +- ui/board.go | 4 +- ui/utils/style.go | 2 +- 19 files changed, 752 insertions(+), 651 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .golangci.yml create mode 100644 .pre-commit-config.yaml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..67b6b47 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,48 @@ +name: CI + +on: + push: + branches: ["**"] + pull_request: + +permissions: + contents: read + +jobs: + test-and-lint: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: "1.25.x" + cache: true + + - name: Show Go environment + run: go env + + - name: Download dependencies + run: go mod download + + - name: Check formatting (gofmt) + run: | + test -z "$(gofmt -l .)" + + - name: Static analysis (golangci-lint) + uses: golangci/golangci-lint-action@v6 + with: + version: latest + + - name: Run tests with coverage + run: TESTING=1 go test ./... -count=1 -cover + + - name: Run race detector (develop only) + if: github.ref == 'refs/heads/develop' + run: TESTING=1 go test ./... -race -count=1 + + - name: CI summary + run: echo "CI completed successfully" diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..c0c0ca5 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,19 @@ +run: + timeout: 3m + tests: true + +linters: + enable: + - govet + - staticcheck + - errcheck + - ineffassign + - unused + +linters-settings: + errcheck: + check-type-assertions: true + check-blank: true + +issues: + exclude-use-default: false diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..ff28423 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,27 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: end-of-file-fixer + - id: trailing-whitespace + - id: check-yaml + + - repo: local + hooks: + - id: gofmt + name: gofmt + entry: gofmt -w + language: system + files: \.go$ + + - id: govet + name: go vet + entry: go vet ./... + language: system + pass_filenames: false + + - id: gotest + name: go test + entry: python -c "import os, subprocess; os.environ['TESTING']='1'; subprocess.check_call(['go','test','./...','-count=1'])" + language: system + pass_filenames: false diff --git a/ai_models/ai.go b/ai_models/ai.go index 8979b6e..f7886bc 100644 --- a/ai_models/ai.go +++ b/ai_models/ai.go @@ -1,7 +1,7 @@ -package ai_models - -import "GoTicTacToe/game" - -type AIModel interface { - NextMove(board *game.Board, me *game.Player, players [2]*game.Player) (x, y int) -} +package ai_models + +import "GoTicTacToe/game" + +type AIModel interface { + NextMove(board *game.Board, me *game.Player, players [2]*game.Player) (x, y int) +} diff --git a/ai_models/minimax.go b/ai_models/minimax.go index fc5ec71..7d36b28 100644 --- a/ai_models/minimax.go +++ b/ai_models/minimax.go @@ -1,65 +1,65 @@ -package ai_models - -import "GoTicTacToe/game" - -type MinimaxAI struct{} - -func (MinimaxAI) NextMove(board *game.Board, me *game.Player, players [2]*game.Player) (int, int) { - bestScore := -9999 - bestMove := game.Move{X: -1, Y: -1} - - for _, mv := range board.AvailableMoves() { - clone := board.Clone() - clone.Play(me, mv.X, mv.Y) - - score := minimax(clone, me, players, false) - - if score > bestScore { - bestScore = score - bestMove = mv - } - } - - return bestMove.X, bestMove.Y -} - -func minimax(board *game.Board, me *game.Player, players [2]*game.Player, maximizing bool) int { - winner := board.CheckWin() - if winner == me { - return +1 - } - if winner != nil && winner != me { - return -1 - } - if board.CheckDraw() { - return 0 - } - - if maximizing { - best := -9999 - for _, mv := range board.AvailableMoves() { - clone := board.Clone() - clone.Play(me, mv.X, mv.Y) - score := minimax(clone, me, players, false) - if score > best { - best = score - } - } - return best - } - - // minimizing (opponent turn) - opp := me.Opponent(players) - best := 9999 - - for _, mv := range board.AvailableMoves() { - clone := board.Clone() - clone.Play(opp, mv.X, mv.Y) - score := minimax(clone, me, players, true) - if score < best { - best = score - } - } - - return best -} +package ai_models + +import "GoTicTacToe/game" + +type MinimaxAI struct{} + +func (MinimaxAI) NextMove(board *game.Board, me *game.Player, players [2]*game.Player) (int, int) { + bestScore := -9999 + bestMove := game.Move{X: -1, Y: -1} + + for _, mv := range board.AvailableMoves() { + clone := board.Clone() + clone.Play(me, mv.X, mv.Y) + + score := minimax(clone, me, players, false) + + if score > bestScore { + bestScore = score + bestMove = mv + } + } + + return bestMove.X, bestMove.Y +} + +func minimax(board *game.Board, me *game.Player, players [2]*game.Player, maximizing bool) int { + winner := board.CheckWin() + if winner == me { + return +1 + } + if winner != nil && winner != me { + return -1 + } + if board.CheckDraw() { + return 0 + } + + if maximizing { + best := -9999 + for _, mv := range board.AvailableMoves() { + clone := board.Clone() + clone.Play(me, mv.X, mv.Y) + score := minimax(clone, me, players, false) + if score > best { + best = score + } + } + return best + } + + // minimizing (opponent turn) + opp := me.Opponent(players) + best := 9999 + + for _, mv := range board.AvailableMoves() { + clone := board.Clone() + clone.Play(opp, mv.X, mv.Y) + score := minimax(clone, me, players, true) + if score < best { + best = score + } + } + + return best +} diff --git a/ai_models/minimax_fuzz_test.go b/ai_models/minimax_fuzz_test.go index fbe627f..4ef7869 100644 --- a/ai_models/minimax_fuzz_test.go +++ b/ai_models/minimax_fuzz_test.go @@ -1,56 +1,56 @@ -package ai_models - -import ( - "GoTicTacToe/game" - "testing" -) - -/* -FuzzMinimaxDoesNotCrash performs fuzz testing on the MinimaxAI algorithm. - -The goal of this test is NOT to verify the quality of the moves, -but to ensure that the Minimax implementation: -- never panics -- always terminates -- behaves safely for a range of board sizes - -The fuzzing engine generates random board sizes within a constrained range -to explore unexpected execution paths. -*/ -func FuzzMinimaxDoesNotCrash(f *testing.F) { - - // Seed corpus: start fuzzing with a valid board size - f.Add(3) - - // Fuzz function executed with randomly generated inputs - f.Fuzz(func(t *testing.T, size int) { - - // Ignore sizes that would produce invalid or unsupported boards - // This keeps the fuzzing focused on realistic game scenarios - if size < 3 || size > 5 { - return - } - - // Create a board with the fuzz-generated size - b := game.NewBoard(size, size) - - // Create AI player and opponent - p1 := &game.Player{Name: "AI"} - p2 := &game.Player{Name: "Human"} - - // Players array required by the AI interface - players := [2]*game.Player{p1, p2} - - // Instantiate the Minimax AI model - ai := MinimaxAI{} - - // Ask the AI to compute a move - x, y := ai.NextMove(b, p1, players) - - // The AI should never return absurd values or crash - // Even in edge cases, coordinates must remain within safe bounds - if x < -1 || y < -1 { - t.Fatalf("invalid move") - } - }) -} +package ai_models + +import ( + "GoTicTacToe/game" + "testing" +) + +/* +FuzzMinimaxDoesNotCrash performs fuzz testing on the MinimaxAI algorithm. + +The goal of this test is NOT to verify the quality of the moves, +but to ensure that the Minimax implementation: +- never panics +- always terminates +- behaves safely for a range of board sizes + +The fuzzing engine generates random board sizes within a constrained range +to explore unexpected execution paths. +*/ +func FuzzMinimaxDoesNotCrash(f *testing.F) { + + // Seed corpus: start fuzzing with a valid board size + f.Add(3) + + // Fuzz function executed with randomly generated inputs + f.Fuzz(func(t *testing.T, size int) { + + // Ignore sizes that would produce invalid or unsupported boards + // This keeps the fuzzing focused on realistic game scenarios + if size < 3 || size > 5 { + return + } + + // Create a board with the fuzz-generated size + b := game.NewBoard(size, size) + + // Create AI player and opponent + p1 := &game.Player{Name: "AI"} + p2 := &game.Player{Name: "Human"} + + // Players array required by the AI interface + players := [2]*game.Player{p1, p2} + + // Instantiate the Minimax AI model + ai := MinimaxAI{} + + // Ask the AI to compute a move + x, y := ai.NextMove(b, p1, players) + + // The AI should never return absurd values or crash + // Even in edge cases, coordinates must remain within safe bounds + if x < -1 || y < -1 { + t.Fatalf("invalid move") + } + }) +} diff --git a/ai_models/minimax_test.go b/ai_models/minimax_test.go index 52a5512..014562c 100644 --- a/ai_models/minimax_test.go +++ b/ai_models/minimax_test.go @@ -1,40 +1,40 @@ -package ai_models - -import ( - "GoTicTacToe/game" - "testing" -) - -/* -TestMinimaxReturnsValidMove verifies that the MinimaxAI model -always returns a valid board coordinate on an empty board. - -This test does NOT attempt to validate the optimality of the move -(minimax correctness), which would require complex scenario-based tests. -Instead, it ensures that: -- the algorithm terminates -- no panic occurs -- the returned move is within board bounds -*/ -func TestMinimaxReturnsValidMove(t *testing.T) { - // Create a standard empty 3x3 board - b := game.NewBoard(3, 3) - - // Create players: one controlled by the AI, one opponent - p1 := &game.Player{Name: "AI"} - p2 := &game.Player{Name: "Human"} - - // Players array required by the AI interface - players := [2]*game.Player{p1, p2} - - // Instantiate the Minimax AI model - ai := MinimaxAI{} - - // Ask the AI to compute the next move - x, y := ai.NextMove(b, p1, players) - - // The returned move must be inside the board boundaries - if x < 0 || x >= 3 || y < 0 || y >= 3 { - t.Errorf("invalid move returned: %d,%d", x, y) - } -} +package ai_models + +import ( + "GoTicTacToe/game" + "testing" +) + +/* +TestMinimaxReturnsValidMove verifies that the MinimaxAI model +always returns a valid board coordinate on an empty board. + +This test does NOT attempt to validate the optimality of the move +(minimax correctness), which would require complex scenario-based tests. +Instead, it ensures that: +- the algorithm terminates +- no panic occurs +- the returned move is within board bounds +*/ +func TestMinimaxReturnsValidMove(t *testing.T) { + // Create a standard empty 3x3 board + b := game.NewBoard(3, 3) + + // Create players: one controlled by the AI, one opponent + p1 := &game.Player{Name: "AI"} + p2 := &game.Player{Name: "Human"} + + // Players array required by the AI interface + players := [2]*game.Player{p1, p2} + + // Instantiate the Minimax AI model + ai := MinimaxAI{} + + // Ask the AI to compute the next move + x, y := ai.NextMove(b, p1, players) + + // The returned move must be inside the board boundaries + if x < 0 || x >= 3 || y < 0 || y >= 3 { + t.Errorf("invalid move returned: %d,%d", x, y) + } +} diff --git a/ai_models/random.go b/ai_models/random.go index 6a84bfa..af32d01 100644 --- a/ai_models/random.go +++ b/ai_models/random.go @@ -1,18 +1,18 @@ -package ai_models - -import ( - "GoTicTacToe/game" - "math/rand" -) - -type RandomAI struct{} - -func (RandomAI) NextMove(board *game.Board, me *game.Player, players [2]*game.Player) (int, int) { - moves := board.AvailableMoves() - if len(moves) == 0 { - return -1, -1 - } - - m := moves[rand.Intn(len(moves))] - return m.X, m.Y -} +package ai_models + +import ( + "GoTicTacToe/game" + "math/rand" +) + +type RandomAI struct{} + +func (RandomAI) NextMove(board *game.Board, me *game.Player, players [2]*game.Player) (int, int) { + moves := board.AvailableMoves() + if len(moves) == 0 { + return -1, -1 + } + + m := moves[rand.Intn(len(moves))] + return m.X, m.Y +} diff --git a/ai_models/random_test.go b/ai_models/random_test.go index fb986a6..959f5e6 100644 --- a/ai_models/random_test.go +++ b/ai_models/random_test.go @@ -1,37 +1,37 @@ -package ai_models - -import ( - "GoTicTacToe/game" - "testing" -) - -/* -TestRandomAIValidMove verifies that the RandomAI model always returns -a valid board coordinate when moves are available. - -The goal of this test is NOT to verify randomness or strategy, -but to ensure that: -- the AI does not crash -- the AI does not return invalid coordinates -*/ -func TestRandomAIValidMove(t *testing.T) { - // Create a standard empty 3x3 board - b := game.NewBoard(3, 3) - - // Create a dummy player controlled by the AI - p := &game.Player{} - - // Create the players array required by the AI interface - players := [2]*game.Player{p, {}} - - // Instantiate the RandomAI model - ai := RandomAI{} - - // Ask the AI to choose a move - x, y := ai.NextMove(b, p, players) - - // The returned coordinates should be valid (non-negative) - if x < 0 || y < 0 { - t.Errorf("random AI returned invalid move") - } -} +package ai_models + +import ( + "GoTicTacToe/game" + "testing" +) + +/* +TestRandomAIValidMove verifies that the RandomAI model always returns +a valid board coordinate when moves are available. + +The goal of this test is NOT to verify randomness or strategy, +but to ensure that: +- the AI does not crash +- the AI does not return invalid coordinates +*/ +func TestRandomAIValidMove(t *testing.T) { + // Create a standard empty 3x3 board + b := game.NewBoard(3, 3) + + // Create a dummy player controlled by the AI + p := &game.Player{} + + // Create the players array required by the AI interface + players := [2]*game.Player{p, {}} + + // Instantiate the RandomAI model + ai := RandomAI{} + + // Ask the AI to choose a move + x, y := ai.NextMove(b, p, players) + + // The returned coordinates should be valid (non-negative) + if x < 0 || y < 0 { + t.Errorf("random AI returned invalid move") + } +} diff --git a/assets/images.go b/assets/images.go index b34ed1b..7a307c0 100644 --- a/assets/images.go +++ b/assets/images.go @@ -2,16 +2,23 @@ package assets import ( "log" + "os" "github.com/hajimehoshi/ebiten/v2" "github.com/hajimehoshi/ebiten/v2/ebitenutil" ) var ( - Logo *ebiten.Image + Logo *ebiten.Image ) func init() { + + // Ne pas charger les assets graphiques pendant les tests ou la CI + if os.Getenv("TESTING") == "1" { + return + } + logoImg, _, err := ebitenutil.NewImageFromFile("assets/static/gonnectmax_logo.png") if err != nil { log.Fatal(err) diff --git a/game/board.go b/game/board.go index d78e301..28e67cb 100644 --- a/game/board.go +++ b/game/board.go @@ -1,9 +1,9 @@ package game // Board represents the game grid and contains player tokens. -// - Cells is a Size x Size matrix of *Player. -// - ToWin defines how many aligned symbols are required to win -// (used for variants such as 4-in-a-row or 5-in-a-row). +// - Cells is a Size x Size matrix of *Player. +// - ToWin defines how many aligned symbols are required to win +// (used for variants such as 4-in-a-row or 5-in-a-row). type Board struct { Cells [][]*Player Size int diff --git a/game/board_test.go b/game/board_test.go index 7c9b658..a7b5c0c 100644 --- a/game/board_test.go +++ b/game/board_test.go @@ -1,61 +1,61 @@ -package game - -import "testing" - -/* -TestBoardPlayAndAvailableMoves verifies the basic behavior of the Board: -- a valid move can be played -- the corresponding cell is updated -- the number of available moves decreases accordingly -*/ -func TestBoardPlayAndAvailableMoves(t *testing.T) { - // Create a standard 3x3 board where 3 aligned symbols are needed to win - b := NewBoard(3, 3) - - // Create a test player - p := &Player{Name: "P1"} - - // Play a valid move in the center of the board - ok := b.Play(p, 1, 1) - if !ok { - t.Fatalf("expected move to be valid") - } - - // The cell (1,1) should now contain the player pointer - if b.Cells[1][1] != p { - t.Errorf("cell not updated correctly") - } - - // After one move on a 3x3 board, 8 moves should remain available - moves := b.AvailableMoves() - if len(moves) != 8 { - t.Errorf("expected 8 available moves, got %d", len(moves)) - } -} - -//================================================================================== - -/* -TestBoardInvalidMove verifies that the Board correctly rejects invalid moves, -such as attempting to play on an already occupied cell. -*/ -func TestBoardInvalidMove(t *testing.T) { - // Create a new empty board - b := NewBoard(3, 3) - - // Create a test player - p := &Player{Name: "P1"} - - // Play a move in the top-left corner - b.Play(p, 0, 0) - - // Attempt to play in the same cell again - ok := b.Play(p, 0, 0) - - // The second move should be rejected - if ok { - t.Errorf("expected move to be rejected") - } -} - -//================================================================================== +package game + +import "testing" + +/* +TestBoardPlayAndAvailableMoves verifies the basic behavior of the Board: +- a valid move can be played +- the corresponding cell is updated +- the number of available moves decreases accordingly +*/ +func TestBoardPlayAndAvailableMoves(t *testing.T) { + // Create a standard 3x3 board where 3 aligned symbols are needed to win + b := NewBoard(3, 3) + + // Create a test player + p := &Player{Name: "P1"} + + // Play a valid move in the center of the board + ok := b.Play(p, 1, 1) + if !ok { + t.Fatalf("expected move to be valid") + } + + // The cell (1,1) should now contain the player pointer + if b.Cells[1][1] != p { + t.Errorf("cell not updated correctly") + } + + // After one move on a 3x3 board, 8 moves should remain available + moves := b.AvailableMoves() + if len(moves) != 8 { + t.Errorf("expected 8 available moves, got %d", len(moves)) + } +} + +//================================================================================== + +/* +TestBoardInvalidMove verifies that the Board correctly rejects invalid moves, +such as attempting to play on an already occupied cell. +*/ +func TestBoardInvalidMove(t *testing.T) { + // Create a new empty board + b := NewBoard(3, 3) + + // Create a test player + p := &Player{Name: "P1"} + + // Play a move in the top-left corner + b.Play(p, 0, 0) + + // Attempt to play in the same cell again + ok := b.Play(p, 0, 0) + + // The second move should be rejected + if ok { + t.Errorf("expected move to be rejected") + } +} + +//================================================================================== diff --git a/game/game_test.go b/game/game_test.go index acf136d..3ca6c2d 100644 --- a/game/game_test.go +++ b/game/game_test.go @@ -1,268 +1,268 @@ -package game - -import "testing" - -/* -TestWinDetection verifies that a simple winning condition -(3 symbols aligned in a row) is correctly detected by the game. - -This test uses Board.Play directly to isolate win detection logic -without involving turn switching. -*/ -func TestWinDetection(t *testing.T) { - g := NewGame() - p := g.Players[0] - - // Player places three symbols in the top row - g.Board.Play(p, 0, 0) - g.Board.Play(p, 1, 0) - g.Board.Play(p, 2, 0) - - // The game should detect a win - if !g.CheckWin() { - t.Fatalf("expected win to be detected") - } - - // The winner should be the player who placed the symbols - if g.Winner != p { - t.Errorf("wrong winner detected") - } -} - -//================================================================================== - -/* -TestDrawDetection verifies that a full board with no winner -is correctly detected as a draw. - -The move sequence fills the board completely while alternating players, -ensuring no winning alignment exists. -*/ -func TestDrawDetection(t *testing.T) { - g := NewGame() - p1 := g.Players[0] - p2 := g.Players[1] - - // Sequence of moves that fills the board without any win - moves := []struct { - p *Player - x int - y int - }{ - {p1, 0, 0}, {p2, 1, 0}, {p1, 2, 0}, - {p1, 0, 1}, {p2, 1, 1}, {p1, 2, 1}, - {p2, 0, 2}, {p1, 1, 2}, {p2, 2, 2}, - } - - for _, m := range moves { - g.Board.Play(m.p, m.x, m.y) - } - - // The game should detect a draw - if !g.CheckDraw() { - t.Fatalf("expected draw to be detected") - } -} - -//================================================================================== - -/* -TestPlayMoveSwitchesPlayer ensures that calling PlayMove -correctly switches the current player after a valid move. (also ensures valid moves) -*/ -func TestPlayMoveSwitchesPlayer(t *testing.T) { - g := NewGame() - first := g.Current - - ok := g.PlayMove(0, 0) - if !ok { - t.Fatalf("valid move rejected") - } - - // The current player should have changed - if g.Current == first { - t.Errorf("expected current player to switch") - } -} - -//================================================================================== - -/* -TestResetHard verifies that ResetHard completely resets the game: -- board is cleared -- winner is cleared -- points are reset -- game state returns to PLAYING -*/ -func TestResetHard(t *testing.T) { - g := NewGame() - - // Modify the game state - g.Players[0].Points = 5 - g.Board.Play(g.Players[0], 0, 0) - - g.ResetHard() - - if g.State != PLAYING { - t.Errorf("expected state PLAYING after ResetHard") - } - - if g.Winner != nil { - t.Errorf("winner should be nil after ResetHard") - } - - if g.Players[0].Points != 0 { - t.Errorf("points should be reset") - } - - // All cells should be empty again - if len(g.Board.AvailableMoves()) != 9 { - t.Errorf("board should be empty after ResetHard") - } -} - -//================================================================================== - -/* -TestResetKeepsPoints ensures that Reset only clears the board -but does NOT reset player scores. -*/ -func TestResetKeepsPoints(t *testing.T) { - g := NewGame() - g.Players[0].Points = 3 - - g.Board.Play(g.Players[0], 0, 0) - g.Reset() - - if g.Players[0].Points != 3 { - t.Errorf("Reset should not reset points") - } - - if len(g.Board.AvailableMoves()) != 9 { - t.Errorf("board should be cleared on Reset") - } -} - -//================================================================================== - -/* -TestResetPoints verifies that ResetPoints correctly resets -the score of all players without modifying the board. -*/ -func TestResetPoints(t *testing.T) { - g := NewGame() - g.Players[0].Points = 2 - g.Players[1].Points = 4 - - g.ResetPoints() - - for i, p := range g.Players { - if p.Points != 0 { - t.Errorf("player %d points not reset", i) - } - } -} - -//================================================================================== - -/* -TestNextPlayerWrapsAround ensures that when the current player -is the last one in the list, NextPlayer wraps back to the first player. -*/ -func TestNextPlayerWrapsAround(t *testing.T) { - g := NewGame() - - g.Current = g.Players[1] - g.NextPlayer() - - if g.Current != g.Players[0] { - t.Errorf("NextPlayer should wrap to first player") - } -} - -//================================================================================== - -/* -TestNextPlayerFallback verifies a defensive behavior: -if Current is not found in the Players slice, -NextPlayer should safely reset it to the first player. -*/ -func TestNextPlayerFallback(t *testing.T) { - g := NewGame() - - // Assign an invalid current player - g.Current = &Player{Name: "ghost"} - - g.NextPlayer() - - if g.Current != g.Players[0] { - t.Errorf("fallback should reset current to first player") - } -} - -//================================================================================== - -/* -TestPlayMoveInvalidDoesNotSwitchPlayer ensures that an invalid move: -- is rejected -- does NOT switch the current player -- playing in the same spot is tested here -*/ -func TestPlayMoveInvalidDoesNotSwitchPlayer(t *testing.T) { - g := NewGame() - start := g.Current - - ok := g.PlayMove(0, 0) - if !ok { - t.Fatalf("first move should be valid") - } - - // Try to play in the same cell again - ok = g.PlayMove(0, 0) - if ok { - t.Errorf("move should be invalid") - } - - // Current player should not change - if g.Current == start { - t.Errorf("current player should not change on invalid move") - } -} - -//================================================================================== - -/* -TestDiagonalWinViaPlayMove tests a full game flow using PlayMove: -- players alternate turns -- a diagonal win is created -- the game state transitions to GAME_END -*/ -func TestDiagonalWinViaPlayMove(t *testing.T) { - g := NewGame() - p1 := g.Players[0] - - // Sequence of moves leading to a diagonal win for p1 - moves := []struct { - x, y int - }{ - {0, 0}, // p1 - {0, 1}, // p2 - {1, 1}, // p1 - {0, 2}, // p2 - {2, 2}, // p1 -> win - } - - for _, m := range moves { - g.PlayMove(m.x, m.y) - } - - if g.Winner != p1 { - t.Errorf("expected diagonal win for p1") - } - - if g.State != GAME_END { - t.Errorf("game should be ended after win") - } -} - -//================================================================================== +package game + +import "testing" + +/* +TestWinDetection verifies that a simple winning condition +(3 symbols aligned in a row) is correctly detected by the game. + +This test uses Board.Play directly to isolate win detection logic +without involving turn switching. +*/ +func TestWinDetection(t *testing.T) { + g := NewGame() + p := g.Players[0] + + // Player places three symbols in the top row + g.Board.Play(p, 0, 0) + g.Board.Play(p, 1, 0) + g.Board.Play(p, 2, 0) + + // The game should detect a win + if !g.CheckWin() { + t.Fatalf("expected win to be detected") + } + + // The winner should be the player who placed the symbols + if g.Winner != p { + t.Errorf("wrong winner detected") + } +} + +//================================================================================== + +/* +TestDrawDetection verifies that a full board with no winner +is correctly detected as a draw. + +The move sequence fills the board completely while alternating players, +ensuring no winning alignment exists. +*/ +func TestDrawDetection(t *testing.T) { + g := NewGame() + p1 := g.Players[0] + p2 := g.Players[1] + + // Sequence of moves that fills the board without any win + moves := []struct { + p *Player + x int + y int + }{ + {p1, 0, 0}, {p2, 1, 0}, {p1, 2, 0}, + {p1, 0, 1}, {p2, 1, 1}, {p1, 2, 1}, + {p2, 0, 2}, {p1, 1, 2}, {p2, 2, 2}, + } + + for _, m := range moves { + g.Board.Play(m.p, m.x, m.y) + } + + // The game should detect a draw + if !g.CheckDraw() { + t.Fatalf("expected draw to be detected") + } +} + +//================================================================================== + +/* +TestPlayMoveSwitchesPlayer ensures that calling PlayMove +correctly switches the current player after a valid move. (also ensures valid moves) +*/ +func TestPlayMoveSwitchesPlayer(t *testing.T) { + g := NewGame() + first := g.Current + + ok := g.PlayMove(0, 0) + if !ok { + t.Fatalf("valid move rejected") + } + + // The current player should have changed + if g.Current == first { + t.Errorf("expected current player to switch") + } +} + +//================================================================================== + +/* +TestResetHard verifies that ResetHard completely resets the game: +- board is cleared +- winner is cleared +- points are reset +- game state returns to PLAYING +*/ +func TestResetHard(t *testing.T) { + g := NewGame() + + // Modify the game state + g.Players[0].Points = 5 + g.Board.Play(g.Players[0], 0, 0) + + g.ResetHard() + + if g.State != PLAYING { + t.Errorf("expected state PLAYING after ResetHard") + } + + if g.Winner != nil { + t.Errorf("winner should be nil after ResetHard") + } + + if g.Players[0].Points != 0 { + t.Errorf("points should be reset") + } + + // All cells should be empty again + if len(g.Board.AvailableMoves()) != 9 { + t.Errorf("board should be empty after ResetHard") + } +} + +//================================================================================== + +/* +TestResetKeepsPoints ensures that Reset only clears the board +but does NOT reset player scores. +*/ +func TestResetKeepsPoints(t *testing.T) { + g := NewGame() + g.Players[0].Points = 3 + + g.Board.Play(g.Players[0], 0, 0) + g.Reset() + + if g.Players[0].Points != 3 { + t.Errorf("Reset should not reset points") + } + + if len(g.Board.AvailableMoves()) != 9 { + t.Errorf("board should be cleared on Reset") + } +} + +//================================================================================== + +/* +TestResetPoints verifies that ResetPoints correctly resets +the score of all players without modifying the board. +*/ +func TestResetPoints(t *testing.T) { + g := NewGame() + g.Players[0].Points = 2 + g.Players[1].Points = 4 + + g.ResetPoints() + + for i, p := range g.Players { + if p.Points != 0 { + t.Errorf("player %d points not reset", i) + } + } +} + +//================================================================================== + +/* +TestNextPlayerWrapsAround ensures that when the current player +is the last one in the list, NextPlayer wraps back to the first player. +*/ +func TestNextPlayerWrapsAround(t *testing.T) { + g := NewGame() + + g.Current = g.Players[1] + g.NextPlayer() + + if g.Current != g.Players[0] { + t.Errorf("NextPlayer should wrap to first player") + } +} + +//================================================================================== + +/* +TestNextPlayerFallback verifies a defensive behavior: +if Current is not found in the Players slice, +NextPlayer should safely reset it to the first player. +*/ +func TestNextPlayerFallback(t *testing.T) { + g := NewGame() + + // Assign an invalid current player + g.Current = &Player{Name: "ghost"} + + g.NextPlayer() + + if g.Current != g.Players[0] { + t.Errorf("fallback should reset current to first player") + } +} + +//================================================================================== + +/* +TestPlayMoveInvalidDoesNotSwitchPlayer ensures that an invalid move: +- is rejected +- does NOT switch the current player +- playing in the same spot is tested here +*/ +func TestPlayMoveInvalidDoesNotSwitchPlayer(t *testing.T) { + g := NewGame() + start := g.Current + + ok := g.PlayMove(0, 0) + if !ok { + t.Fatalf("first move should be valid") + } + + // Try to play in the same cell again + ok = g.PlayMove(0, 0) + if ok { + t.Errorf("move should be invalid") + } + + // Current player should not change + if g.Current == start { + t.Errorf("current player should not change on invalid move") + } +} + +//================================================================================== + +/* +TestDiagonalWinViaPlayMove tests a full game flow using PlayMove: +- players alternate turns +- a diagonal win is created +- the game state transitions to GAME_END +*/ +func TestDiagonalWinViaPlayMove(t *testing.T) { + g := NewGame() + p1 := g.Players[0] + + // Sequence of moves leading to a diagonal win for p1 + moves := []struct { + x, y int + }{ + {0, 0}, // p1 + {0, 1}, // p2 + {1, 1}, // p1 + {0, 2}, // p2 + {2, 2}, // p1 -> win + } + + for _, m := range moves { + g.PlayMove(m.x, m.y) + } + + if g.Winner != p1 { + t.Errorf("expected diagonal win for p1") + } + + if g.State != GAME_END { + t.Errorf("game should be ended after win") + } +} + +//================================================================================== diff --git a/game/moves.go b/game/moves.go index 4bd247f..87dec3a 100644 --- a/game/moves.go +++ b/game/moves.go @@ -1,6 +1,6 @@ -package game - -type Move struct { - X int - Y int -} +package game + +type Move struct { + X int + Y int +} diff --git a/screens/ai_screen.go b/screens/ai_screen.go index 1623850..0ab4188 100644 --- a/screens/ai_screen.go +++ b/screens/ai_screen.go @@ -1,66 +1,66 @@ -package screens - -import ( - "GoTicTacToe/ai_models" - "GoTicTacToe/ui" - uiutils "GoTicTacToe/ui/utils" - - "github.com/hajimehoshi/ebiten/v2" -) - -type AIScreen struct { - host ScreenHost - cfg GameConfig - btns []*ui.Button -} - -func NewAIScreen(h ScreenHost, cfg GameConfig) *AIScreen { - s := &AIScreen{ - host: h, - cfg: cfg, - } - - s.btns = []*ui.Button{ - - ui.NewButton("Easy", 0, -60, uiutils.AnchorCenter, - buttonWidth, buttonHeight, buttonRadius, - uiutils.DefaultWidgetStyle, - func() { - cfg.AIModel = ai_models.RandomAI{} - h.SetScreen(NewGameScreen(h, cfg)) - }, - ), - - ui.NewButton("Hard", 0, 0, uiutils.AnchorCenter, - buttonWidth, buttonHeight, buttonRadius, - uiutils.DefaultWidgetStyle, - func() { - cfg.AIModel = ai_models.MinimaxAI{} - h.SetScreen(NewGameScreen(h, cfg)) - }, - ), - - ui.NewButton("Back", 0, 80, uiutils.AnchorCenter, - buttonWidth, buttonHeight, buttonRadius, - uiutils.TransparentWidgetStyle, - func() { - h.SetScreen(NewStartScreen(h)) - }, - ), - } - - return s -} - -func (s *AIScreen) Update() error { - for _, b := range s.btns { - b.Update() - } - return nil -} - -func (s *AIScreen) Draw(screen *ebiten.Image) { - for _, b := range s.btns { - b.Draw(screen) - } -} +package screens + +import ( + "GoTicTacToe/ai_models" + "GoTicTacToe/ui" + uiutils "GoTicTacToe/ui/utils" + + "github.com/hajimehoshi/ebiten/v2" +) + +type AIScreen struct { + host ScreenHost + cfg GameConfig + btns []*ui.Button +} + +func NewAIScreen(h ScreenHost, cfg GameConfig) *AIScreen { + s := &AIScreen{ + host: h, + cfg: cfg, + } + + s.btns = []*ui.Button{ + + ui.NewButton("Easy", 0, -60, uiutils.AnchorCenter, + buttonWidth, buttonHeight, buttonRadius, + uiutils.DefaultWidgetStyle, + func() { + cfg.AIModel = ai_models.RandomAI{} + h.SetScreen(NewGameScreen(h, cfg)) + }, + ), + + ui.NewButton("Hard", 0, 0, uiutils.AnchorCenter, + buttonWidth, buttonHeight, buttonRadius, + uiutils.DefaultWidgetStyle, + func() { + cfg.AIModel = ai_models.MinimaxAI{} + h.SetScreen(NewGameScreen(h, cfg)) + }, + ), + + ui.NewButton("Back", 0, 80, uiutils.AnchorCenter, + buttonWidth, buttonHeight, buttonRadius, + uiutils.TransparentWidgetStyle, + func() { + h.SetScreen(NewStartScreen(h)) + }, + ), + } + + return s +} + +func (s *AIScreen) Update() error { + for _, b := range s.btns { + b.Update() + } + return nil +} + +func (s *AIScreen) Draw(screen *ebiten.Image) { + for _, b := range s.btns { + b.Draw(screen) + } +} diff --git a/screens/game_config.go b/screens/game_config.go index 51f3d3d..8b59471 100644 --- a/screens/game_config.go +++ b/screens/game_config.go @@ -1,15 +1,15 @@ -package screens - -import "GoTicTacToe/ai_models" - -type GameMode int - -const ( - LocalVsLocal GameMode = iota - LocalVsAI -) - -type GameConfig struct { - Mode GameMode - AIModel ai_models.AIModel // nil if not used -} +package screens + +import "GoTicTacToe/ai_models" + +type GameMode int + +const ( + LocalVsLocal GameMode = iota + LocalVsAI +) + +type GameConfig struct { + Mode GameMode + AIModel ai_models.AIModel // nil if not used +} diff --git a/screens/start_screen.go b/screens/start_screen.go index ef4cdd0..6a81d6e 100644 --- a/screens/start_screen.go +++ b/screens/start_screen.go @@ -16,9 +16,9 @@ type StartScreen struct { } const ( - buttonWidth = float64(200) - buttonHeight = float64(64) - buttonRadius = float64(10) + buttonWidth = float64(200) + buttonHeight = float64(64) + buttonRadius = float64(10) buttonSpacing = float64(20) buttonYOffset = float64(140) ) @@ -27,7 +27,7 @@ func NewStartScreen(h ScreenHost) *StartScreen { s := &StartScreen{host: h} s.buttons = []*ui.Button{ - ui.NewButton("Local", -buttonWidth - buttonSpacing, buttonYOffset, uiutils.AnchorCenter, + ui.NewButton("Local", -buttonWidth-buttonSpacing, buttonYOffset, uiutils.AnchorCenter, buttonWidth, buttonHeight, buttonRadius, uiutils.NormalWidgetStyle, func() { s.host.SetScreen(NewGameScreen(s.host, GameConfig{ @@ -48,7 +48,7 @@ func NewStartScreen(h ScreenHost) *StartScreen { ui.NewButton( "Multiplayer \n (not implemented)", - buttonWidth + buttonSpacing, buttonYOffset, + buttonWidth+buttonSpacing, buttonYOffset, uiutils.AnchorCenter, buttonWidth, buttonHeight, buttonRadius, uiutils.DangerWidgetStyle, diff --git a/ui/board.go b/ui/board.go index 4e6a251..079440b 100644 --- a/ui/board.go +++ b/ui/board.go @@ -13,8 +13,8 @@ import ( type BoardView struct { Widget // Embeds Widget: inherits size, position, anchor, AbsPosition(), etc. - logicBoard *game.Board // Reference to the logical board - OnCellClick func(cx, cy int) // Callback triggered when a cell is clicked + logicBoard *game.Board // Reference to the logical board + OnCellClick func(cx, cy int) // Callback triggered when a cell is clicked } // NewBoardView creates a new visual board component. diff --git a/ui/utils/style.go b/ui/utils/style.go index a27675d..c7e482d 100644 --- a/ui/utils/style.go +++ b/ui/utils/style.go @@ -48,7 +48,7 @@ var TransparentWidgetStyle = WidgetStyle{ var NormalWidgetStyle = WidgetStyle{ BackgroundNormal: color.RGBA{64, 92, 245, 100}, - BackgroundHover: color.RGBA{43,61,163, 255}, + BackgroundHover: color.RGBA{43, 61, 163, 255}, TextColor: color.White,