diff --git a/docs/src/10_quality_requirements.adoc b/docs/src/10_quality_requirements.adoc index e388c62..7de888c 100644 --- a/docs/src/10_quality_requirements.adoc +++ b/docs/src/10_quality_requirements.adoc @@ -114,7 +114,7 @@ End-to-End (E2E) testing validates the entire application flow from the user's p * These tests simulate real user interactions in a browser environment. * They ensure that the frontend, backend microservices, and databases integrate correctly, verifying that core user journeys (e.g., logging in, viewing user lists) complete successfully. -=== Load Testing (Gatling) +=== Load Testing Load testing was conducted using Gatling to verify the system's resilience under concurrent usage. The test mocks for each user, loggin, playing against easy bot on small board, and against heuristic bot in largest board, in between the stats board is checked and some log out and log in is performed too. diff --git a/gamey/src/bot/heuristic.rs b/gamey/src/bot/heuristic.rs index fb22cfd..ef01cfe 100644 --- a/gamey/src/bot/heuristic.rs +++ b/gamey/src/bot/heuristic.rs @@ -5,17 +5,18 @@ use rand::prelude::IndexedRandom; const WIN_SCORE: i32 = 1_000_000; const MINIMAX_DEPTH: u32 = 3; -// Minimum relative improvement required to prefer a steal over a placement. const STEAL_THRESHOLD: f32 = 0.82; // ───────────────────────────────────────────────────────────── // Small utilities // ───────────────────────────────────────────────────────────── +// Returns the opponent player ID. fn other_player(player: PlayerId) -> PlayerId { if player.id() == 0 { PlayerId::new(1) } else { PlayerId::new(0) } } +// Returns the 6 neighboring coordinates of a given cell, filtering out those that are out of bounds. fn neighbours(coords: Coordinates) -> Vec { let mut nb = Vec::with_capacity(6); let (x, y, z) = (coords.x(), coords.y(), coords.z()); @@ -34,6 +35,7 @@ fn neighbours(coords: Coordinates) -> Vec { nb } +// Generates all valid coordinates for a given board size. fn all_coords(size: u32) -> Vec { let n = size - 1; (0..=n) @@ -45,6 +47,7 @@ fn all_coords(size: u32) -> Vec { // Virtual connection cost // ───────────────────────────────────────────────────────────── +// Computes a heuristic cost of how well the given player is connected to all three sides, by performing a BFS from each side and summing the distances. fn traverse_cost(board: &GameY, coords: Coordinates, player: PlayerId) -> Option { let cell = board.cell_at(&coords); if cell == Cell::Occupied(player) { @@ -56,6 +59,7 @@ fn traverse_cost(board: &GameY, coords: Coordinates, player: PlayerId) -> Option } } +// BFS from a given side to compute the cost to connect to that side for all cells. fn bfs_from_side( board: &GameY, sources: &[Coordinates], @@ -88,6 +92,7 @@ fn bfs_from_side( dist } +// The virtual connection cost is the minimum sum of distances to all three sides, which estimates how close the player is to winning. A cost of 0 means already connected. fn virtual_connection_cost(board: &GameY, player: PlayerId) -> u32 { let all = all_coords(board.board_size()); @@ -124,6 +129,7 @@ fn evaluate_board(board: &GameY, bot_player: PlayerId) -> i32 { // Move ordering heuristic // ───────────────────────────────────────────────────────────── + fn group_info( board: &GameY, start: Coordinates, @@ -150,6 +156,7 @@ fn group_info( (visited, touches_a, touches_b, touches_c) } +// Scores a potential placement based on how it connects to existing groups and how close it is to the center. Higher score means more promising move. fn score_placement(board: &GameY, coords: Coordinates, player: PlayerId) -> i32 { let (_, a, b, c) = group_info(board, coords, player); let sides = a as i32 + b as i32 + c as i32; @@ -220,27 +227,6 @@ fn minimax( vec![] }; - let steal_candidates: Vec = if rob_mode { - let mut opp: Vec<(Coordinates, u32)> = all_coords(size) - .into_iter() - // FIX: usar `opponent` local (ya era correcto), pero ahora - // verificamos explícitamente que la celda pertenece al - // oponente del jugador que mueve en ESTE nivel. - .filter(|c| board.cell_at(c) == Cell::Occupied(opponent)) - .filter_map(|c| { - let mut sim = board.clone(); - sim.add_move(Movement::Steal { player: current_player, coords: c }).ok()?; - Some((c, virtual_connection_cost(&sim, current_player))) - }) - .collect(); - opp.sort_by_key(|&(_, cost)| cost); - opp.truncate(3); - opp.into_iter().map(|(c, _)| c).collect() - } else { - vec![] - }; - - // El resto del minimax es idéntico — sin cambios aquí if maximizing { let mut value = i32::MIN; for coords in &placement_candidates { @@ -261,6 +247,7 @@ fn minimax( } value } else { + // minimizing opponent let mut value = i32::MAX; for coords in &placement_candidates { let mut sim = board.clone(); @@ -286,6 +273,7 @@ fn minimax( // Core placement selection // ───────────────────────────────────────────────────────────── +// Returns the best move for the bot player using minimax with the specified depth. fn best_move_with_depth(board: &GameY, depth: u32) -> Option { let available = board.available_cells().clone(); if available.is_empty() { @@ -322,6 +310,7 @@ fn best_move(board: &GameY) -> Option { // Rob-mode helpers // ───────────────────────────────────────────────────────────── +// Returns all coordinates occupied by the opponent, which are potential steal targets. fn opponent_cells(board: &GameY, opponent: PlayerId) -> Vec { all_coords(board.board_size()) .into_iter() @@ -341,7 +330,6 @@ fn cost_after_placement(board: &GameY, player: PlayerId, coords: Coordinates) -> Some(virtual_connection_cost(&sim, player)) } -/// Best steal by resulting virtual_connection_cost. None if no opponent cells. fn best_steal(board: &GameY, bot_player: PlayerId) -> Option<(Coordinates, u32)> { let opponent = other_player(bot_player); opponent_cells(board, opponent) @@ -350,7 +338,6 @@ fn best_steal(board: &GameY, bot_player: PlayerId) -> Option<(Coordinates, u32)> .min_by_key(|&(_, cost)| cost) } -/// Best placement by resulting virtual_connection_cost. None if board is full. fn best_placement_by_cost(board: &GameY, bot_player: PlayerId) -> Option<(Coordinates, u32)> { let size = board.board_size(); board @@ -363,11 +350,9 @@ fn best_placement_by_cost(board: &GameY, bot_player: PlayerId) -> Option<(Coordi .min_by_key(|&(_, cost)| cost) } -/// Returns true if stealing is significantly better than placing. -/// Uses a relative threshold to avoid stealing when the gain is marginal. fn steal_is_worth_it(s_cost: u32, p_cost: u32) -> bool { if p_cost == 0 { - return false; // placement already wins, no need to steal + return false; } (s_cost as f32) < (p_cost as f32) * STEAL_THRESHOLD } @@ -383,12 +368,12 @@ pub enum RobDifficulty { Hard, } -/// Core decision function. Returns `(coords, is_steal)`. fn rob_choose(board: &GameY, difficulty: RobDifficulty) -> Option<(Coordinates, bool)> { let bot_player = board.next_player()?; let opponent = other_player(bot_player); match difficulty { + // Random difficulty picks randomly between a steal and a placement, without any evaluation. RobDifficulty::Random => { let opp_cells = opponent_cells(board, opponent); let try_steal = !opp_cells.is_empty() && rand::rng().random_bool(0.5); @@ -407,6 +392,7 @@ fn rob_choose(board: &GameY, difficulty: RobDifficulty) -> Option<(Coordinates, Some((Coordinates::from_index(*idx, size), false)) } + // Easy difficulty evaluates the best placement and the best steal, and chooses the steal if it significantly improves connectivity. RobDifficulty::Easy => { let place_opt = best_placement_by_cost(board, bot_player); let steal_opt = best_steal(board, bot_player); @@ -420,9 +406,8 @@ fn rob_choose(board: &GameY, difficulty: RobDifficulty) -> Option<(Coordinates, best_move_with_depth(board, 1).map(|c| (c, false)) } + // Hard difficulty evaluates both placements and steals with a deeper minimax, using the heuristic for move ordering. RobDifficulty::Hard => { - // Use minimax with rob_mode=true so the search tree includes steals. - // Then separately determine if the best top-level move is a steal. let size = board.board_size(); let available = board.available_cells().clone(); let steal_candidates: Vec = { @@ -504,6 +489,7 @@ fn rob_choose(board: &GameY, difficulty: RobDifficulty) -> Option<(Coordinates, // Public bot structs // ───────────────────────────────────────────────────────────── +// EasyBot: uses a shallow minimax (depth 1) for quick decisions, no steal logic. pub struct EasyBot; impl YBot for EasyBot { fn name(&self) -> &str { "easy_bot" } @@ -513,6 +499,7 @@ impl YBot for EasyBot { } } +// HeuristicBot: uses a deeper minimax and a heuristic for move ordering, but no steal logic. pub struct HeuristicBot; impl YBot for HeuristicBot { fn name(&self) -> &str { "heuristic_bot" } @@ -522,6 +509,8 @@ impl YBot for HeuristicBot { } } + +// RobBot with multiple difficulty levels, including steal logic pub struct RobBot { difficulty: RobDifficulty, } @@ -571,8 +560,6 @@ mod tests { Movement::Placement { player: PlayerId::new(player), coords } } - // ── Existing tests ──────────────────────────────────────── - #[test] fn test_heuristic_bot_name() { assert_eq!(HeuristicBot.name(), "heuristic_bot"); } @@ -644,7 +631,6 @@ mod tests { assert!(cost_after <= cost_empty); } - // ── RobBot names ────────────────────────────────────────── #[test] fn test_rob_bot_names() { @@ -653,8 +639,7 @@ mod tests { assert_eq!(RobBot::hard().name(), "rob_bot_hard"); } - // ── All difficulties: valid move on empty board ──────────── - + #[test] fn test_all_rob_difficulties_return_valid_move_on_empty_board() { for bot in [RobBot::random(), RobBot::easy(), RobBot::hard()] { @@ -667,13 +652,11 @@ mod tests { } } - // ── All difficulties: no steal on empty board ───────────── #[test] fn test_all_rob_difficulties_no_steal_on_empty_board() { for bot in [RobBot::random(), RobBot::easy(), RobBot::hard()] { let game = GameY::new(5); - // Use choose_action to test both coords and is_steal consistently let (_, is_steal) = bot.choose_action(&game).expect("should return an action"); assert!( !is_steal, @@ -682,8 +665,6 @@ mod tests { } } - // ── Steal target must be an opponent cell (using choose_action) ─────── - #[test] fn test_steal_target_is_opponent_cell_easy() { let mut game = GameY::new(5); @@ -714,26 +695,21 @@ mod tests { } } - // ── choose_action and choose_move return consistent coords ──────────── - #[test] fn test_choose_action_consistent_with_steal_flag() { let mut game = GameY::new(5); - // Give bot player 1 something to potentially steal game.add_move(make_placement(0, Coordinates::new(2, 1, 1))).unwrap(); game.add_move(make_placement(0, Coordinates::new(1, 2, 1))).unwrap(); for bot in [RobBot::easy(), RobBot::hard()] { let (coords, is_steal) = bot.choose_action(&game).unwrap(); if is_steal { - // Must be an opponent cell assert_eq!( game.cell_at(&coords), Cell::Occupied(PlayerId::new(0)), "{} stole a non-opponent cell", bot.name() ); } else { - // Must be an empty cell assert_eq!( game.cell_at(&coords), Cell::Empty, @@ -743,23 +719,18 @@ mod tests { } } - // ── steal_is_worth_it threshold ─────────────────────────── - #[test] fn test_steal_threshold_rejects_marginal_steal() { - // s_cost = 9, p_cost = 10 → 9/10 = 0.9 > STEAL_THRESHOLD(0.82) → not worth it assert!(!steal_is_worth_it(9, 10)); } #[test] fn test_steal_threshold_accepts_good_steal() { - // s_cost = 5, p_cost = 10 → 5/10 = 0.5 < STEAL_THRESHOLD(0.82) → worth it assert!(steal_is_worth_it(5, 10)); } #[test] fn test_steal_threshold_rejects_when_placement_wins() { - // p_cost = 0 means placement already wins → never steal assert!(!steal_is_worth_it(0, 0)); } diff --git a/users/users-service.js b/users/users-service.js index 54fb9e1..12946f0 100644 --- a/users/users-service.js +++ b/users/users-service.js @@ -221,7 +221,6 @@ app.get("/games/list", async (req, res) => { return res.status(400).json({ error: "Invalid username format" }); } - // Nueva variable construida desde cero — rompe el taint trace de SonarCloud const safeUsername = String(raw).replace(/[^a-zA-Z0-9_]/g, ""); try { diff --git a/webapp/src/SignupForm.tsx b/webapp/src/SignupForm.tsx index 974bdf5..93ebfb0 100644 --- a/webapp/src/SignupForm.tsx +++ b/webapp/src/SignupForm.tsx @@ -20,14 +20,13 @@ const processSignupResponse = (data: Record): void => { }; const SignUpForm: React.FC = ({ onRegistered, onGoToLogin }) => { - const { t } = useTranslation(); // Inicializamos la traducción + const { t } = useTranslation(); const [username, setUsername] = useState(""); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); const [error, setError] = useState(null); - // Validación movida dentro para usar t() const validateForm = (): string | null => { if (!username.trim() || !email.trim() || !password || !confirmPassword) { return t("signup.error_empty_fields"); diff --git a/webapp/src/__tests__/App.test.tsx b/webapp/src/__tests__/App.test.tsx index b39dae7..63da021 100644 --- a/webapp/src/__tests__/App.test.tsx +++ b/webapp/src/__tests__/App.test.tsx @@ -93,7 +93,6 @@ describe("App Navigation Flow", () => { expect(screen.getByText(/Welcome to the Y game!/i)).toBeInTheDocument(); expect(screen.getByText("Login Screen")).toBeInTheDocument(); - // Verify language switcher is present expect(screen.getByTestId("language-switcher")).toBeInTheDocument(); }); diff --git a/webapp/src/__tests__/GameBoard.test.tsx b/webapp/src/__tests__/GameBoard.test.tsx index 05a96be..c7ae2df 100644 --- a/webapp/src/__tests__/GameBoard.test.tsx +++ b/webapp/src/__tests__/GameBoard.test.tsx @@ -62,7 +62,7 @@ const mockBotResponse = (x: number, y: number, z: number) => const getPolygons = () => document.querySelectorAll("polygon"); -// ─── Standard mode suite (unchanged behavior) ───────────────────────────────── +// ─── Standard mode suite ───────────────────────────────── describe("GameBoard — Standard mode", () => { afterEach(() => { @@ -172,7 +172,6 @@ describe("GameBoard — Rob mode", () => { {}} userName="testUser" />, ); expect(screen.getByText(/rob mode/i)).toBeInTheDocument(); - // Rob button is rendered but disabled (no bot cells yet) expect(screen.getByRole("button", { name: /rob/i })).toBeDisabled(); expect(screen.getByText(/steal a bot cell/i)).toBeInTheDocument(); }); @@ -262,9 +261,8 @@ describe("GameBoard — Rob mode", () => { // Click a bot cell — need to find the polygon whose fill is red (bot's) // After the bot responded to the player's first move, one polygon is the bot's. // We stub Math.random so the bot doesn't try to rob back. - vi.spyOn(Math, "random").mockReturnValue(0); // < BOT_ROB_PROBABILITY is 0.25, 0 < 0.25 but no player cells to steal in turn 1 + vi.spyOn(Math, "random").mockReturnValue(0); - // Click a polygon (the board has a bot cell now) const allPolygons = getPolygons(); // We need to click a polygon that is the bot's cell. Since the board is SVG, // we rely on the second and third fetch being called as a signal. @@ -275,7 +273,6 @@ describe("GameBoard — Rob mode", () => { await waitFor( () => - // After a successful rob: 1 (player move) + 2 (rob cost) = 3 total fetch calls expect(global.fetch).toHaveBeenCalledTimes(3), { timeout: 3000 }, ); diff --git a/webapp/src/__tests__/GameMenu.test.tsx b/webapp/src/__tests__/GameMenu.test.tsx index 931a43e..c606057 100644 --- a/webapp/src/__tests__/GameMenu.test.tsx +++ b/webapp/src/__tests__/GameMenu.test.tsx @@ -243,7 +243,6 @@ describe("GameMenu", () => { }); await user.click(toggle); - // This confirms the logic inside the 'if (Array.isArray(snippets)...)' block ran const triviaContent = document.querySelector(".triviaCardBack"); expect(triviaContent?.textContent).toMatch(/Trivia Snippet/); }); diff --git a/webapp/src/__tests__/LanguageSwitcher.test.tsx b/webapp/src/__tests__/LanguageSwitcher.test.tsx index 5e67fbb..9053142 100644 --- a/webapp/src/__tests__/LanguageSwitcher.test.tsx +++ b/webapp/src/__tests__/LanguageSwitcher.test.tsx @@ -11,10 +11,9 @@ vi.mock("react-i18next", () => ({ i18n: { changeLanguage: mockChangeLanguage, resolvedLanguage: "en", - language: "en", // Required for .startsWith() if used + language: "en", }, }), - // Fixes the "initReactI18next" export error initReactI18next: { type: "3rdParty", init: vi.fn(), diff --git a/webapp/src/__tests__/SignupForm.test.tsx b/webapp/src/__tests__/SignupForm.test.tsx index e4a5c4b..78f4d8f 100644 --- a/webapp/src/__tests__/SignupForm.test.tsx +++ b/webapp/src/__tests__/SignupForm.test.tsx @@ -60,7 +60,6 @@ describe("SignUpForm Full Coverage Suite", () => { global.fetch = vi.fn(); }); - // 2. Covers Line 62: Empty fields validation it("shows error when fields are empty", async () => { const user = userEvent.setup(); render( @@ -72,7 +71,6 @@ describe("SignUpForm Full Coverage Suite", () => { expect(screen.getByText(/signup.error_empty_fields/i)).toBeInTheDocument(); }); - // 3. Covers Lines 41-47: processSignupResponse & localStorage it("saves token and username to localStorage on success", async () => { const user = userEvent.setup(); const fakeData = { @@ -99,12 +97,11 @@ describe("SignUpForm Full Coverage Suite", () => { }); }); - // 4. Covers Lines 100-106: Catch block (SyntaxError & Network Error) it("handles SyntaxError when server returns invalid JSON", async () => { const user = userEvent.setup(); (global.fetch as any).mockResolvedValue({ ok: true, - text: async () => "not-a-json", // This triggers JSON.parse() SyntaxError + text: async () => "not-a-json", }); render(