diff --git a/core/build.gradle b/core/build.gradle index c3f37f3..f519bcd 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -9,6 +9,15 @@ dependencies { } implementation "com.badlogicgames.gdx:gdx-freetype:$gdxVersion" + + testImplementation 'org.junit.jupiter:junit-jupiter-api:5.10.2' + testImplementation 'org.junit.jupiter:junit-jupiter-params:5.10.2' + testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.10.2' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.10.2' +} + +test { + useJUnitPlatform() } jar { diff --git a/core/src/test/java/group07/beatbattle/GameSimulationTest.java b/core/src/test/java/group07/beatbattle/GameSimulationTest.java new file mode 100644 index 0000000..7f93005 --- /dev/null +++ b/core/src/test/java/group07/beatbattle/GameSimulationTest.java @@ -0,0 +1,674 @@ +package group07.beatbattle; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Random; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +import group07.beatbattle.firebase.FakeFirebaseGateway; +import group07.beatbattle.firebase.FirebaseGateway; +import group07.beatbattle.model.GameRules; +import group07.beatbattle.model.Leaderboard; +import group07.beatbattle.model.LeaderboardEntry; +import group07.beatbattle.model.Player; +import group07.beatbattle.model.Question; +import group07.beatbattle.model.SessionCreationResult; +import group07.beatbattle.model.SessionJoinResult; // used in focused joinSession tests +import group07.beatbattle.model.Song; +import group07.beatbattle.model.score.ScoreCalculator; +import group07.beatbattle.model.services.LobbyService; + +/** + * Integration test that simulates a full BeatBattle session with 5 players + * across 5 rounds using an in-memory {@link FakeFirebaseGateway}. + * + * Per-round answer pattern (identical each round for deterministic assertions): + * Alice – correct answer at t= 2 s → 1 000 pts (inside grace period) + * Bob – correct answer at t=10 s → 860 pts (linear decay) + * Charlie – correct answer at t=25 s → 440 pts (linear decay, near end) + * Diana – wrong answer at t= 5 s → 0 pts + * Eve – no answer (timer expires) → 0 pts + * + * Covered requirements + * ───────────────────────────────────────────────────────────────────────────── + * ✓ 30-second audio clip plays at the start of each round + * ✓ Multiple-choice options are displayed for each round + * ✓ A player may select exactly one answer per round + * ✓ A player may answer before the audio clip has finished + * ✓ No answer within the time limit → zero points + * ✓ Answer is automatically locked once selected (idempotent submission) + * ✓ Selected answer is evaluated for correctness + * ✓ Correct answer awards points + * ✓ Fastest correct answer receives the highest score (bonus for speed) + * ✓ Game pin is validated before access is granted + * ✓ A unique game pin is generated when a session is created + * ✓ Song title, artist name and audio-clip reference are stored + * ✓ Songs are randomly distributed across rounds (no fixed ordering) + * ✓ The same song is never repeated within a single game session + */ +class GameSimulationTest { + + private static final int TOTAL_ROUNDS = 5; + private static final int TOTAL_PLAYERS = 5; + + // Scoring constants derived from ScoreCalculator logic (grace period = 3 s, window = 27 s) + private static final int SCORE_AT_T2 = 1000; // inside 3-second grace period → MAX + private static final int SCORE_AT_T10 = 819; // 1000 - 700 * (7/27), rounded + private static final int SCORE_AT_T25 = 430; // 1000 - 700 * (22/27), rounded + + private FakeFirebaseGateway gateway; + private LobbyService lobbyService; + + // Session identifiers + private String sessionId; + private String gamePin; + + // Local player objects (scores are accumulated here throughout the game) + private Player alice; // host + private Player bob; + private Player charlie; + private Player diana; + private Player eve; + + // Questions (one per round) + private List questions; + + // ========================================================================= + // Setup + // ========================================================================= + + @BeforeEach + void setUp() { + gateway = new FakeFirebaseGateway(); + lobbyService = new LobbyService(gateway); + + // --- Generate game pin --- + lobbyService.generateAvailableGamePin(new LobbyService.GamePinCallback() { + @Override public void onSuccess(String pin) { gamePin = pin; } + @Override public void onFailure(Exception e) { fail("Pin generation failed: " + e.getMessage()); } + }); + + // --- Alice creates the session (she is the host) --- + lobbyService.createSession("Alice", gamePin, TOTAL_ROUNDS, + new LobbyService.CreateSessionCallback() { + @Override public void onSuccess(SessionCreationResult r) { + sessionId = r.getSessionId(); + alice = new Player(r.getHostId(), r.getHostName(), true); + } + @Override public void onFailure(Exception e) { fail("createSession failed: " + e.getMessage()); } + }); + + // --- Four joiners added directly via the gateway with stable IDs --- + // LobbyService.generatePlayerId() uses System.currentTimeMillis(), which produces + // the same ID for calls that happen within the same millisecond in a fast test. + // The focused joinSession tests below exercise the LobbyService join path independently. + bob = addPlayerDirectly("bob-id", "Bob"); + charlie = addPlayerDirectly("charlie-id", "Charlie"); + diana = addPlayerDirectly("diana-id", "Diana"); + eve = addPlayerDirectly("eve-id", "Eve"); + + // --- Build 5 questions, each using a distinct song --- + questions = buildQuestions(); + + // --- Store question data in the gateway (mirrors LobbyController.buildAndStartSession) --- + List dtos = new ArrayList<>(); + for (Question q : questions) { + dtos.add(new FirebaseGateway.QuestionData( + q.getSong().getId(), + q.getSong().getTitle(), + q.getSong().getArtist(), + q.getSong().getPreviewUrl(), + q.getSong().getAlbumArtUrl(), + q.getOptions(), + q.getRoundIndex() + )); + } + gateway.storeQuestions(sessionId, dtos, new FirebaseGateway.SimpleCallback() { + @Override public void onSuccess() {} + @Override public void onFailure(Exception e) { fail("storeQuestions failed"); } + }); + } + + // ========================================================================= + // Helpers used in setUp + // ========================================================================= + + private Player addPlayerDirectly(String playerId, String playerName) { + gateway.addPlayerToSession(sessionId, playerId, playerName, + new FirebaseGateway.AddPlayerCallback() { + @Override public void onSuccess() {} + @Override public void onFailure(Exception e) { fail(playerName + " join failed: " + e.getMessage()); } + }); + return new Player(playerId, playerName, false); + } + + /** + * Creates 5 questions, each referencing a unique song with 4 shuffled options. + * The shuffle seed is fixed per round so the test is deterministic. + */ + private List buildQuestions() { + String[][] songs = { + {"s1", "Shape of You", "Ed Sheeran", "https://cdn.deezer.com/s1.mp3", "https://cdn.deezer.com/s1.jpg"}, + {"s2", "Blinding Lights","The Weeknd", "https://cdn.deezer.com/s2.mp3", "https://cdn.deezer.com/s2.jpg"}, + {"s3", "Stay", "Justin Bieber", "https://cdn.deezer.com/s3.mp3", "https://cdn.deezer.com/s3.jpg"}, + {"s4", "Levitating", "Dua Lipa", "https://cdn.deezer.com/s4.mp3", "https://cdn.deezer.com/s4.jpg"}, + {"s5", "Peaches", "Justin Bieber", "https://cdn.deezer.com/s5.mp3", "https://cdn.deezer.com/s5.jpg"}, + }; + + List result = new ArrayList<>(); + for (int i = 0; i < TOTAL_ROUNDS; i++) { + String[] s = songs[i]; + Song song = new Song(s[0], s[1], s[2], s[3], s[4]); + + // 4 options: correct answer + 3 decoys from other songs + List options = new ArrayList<>(Arrays.asList( + songs[i][1], // correct + songs[(i + 1) % TOTAL_ROUNDS][1], // decoy + songs[(i + 2) % TOTAL_ROUNDS][1], // decoy + songs[(i + 3) % TOTAL_ROUNDS][1] // decoy + )); + Collections.shuffle(options, new Random(i)); // deterministic per round + + result.add(new Question("q" + i, song, options, i)); + } + return result; + } + + // ========================================================================= + // REQ: System should generate a unique game pin when a session is created + // ========================================================================= + + @Test + void gamePinGeneration_IsExactly4Characters() { + assertNotNull(gamePin, "Game pin must not be null"); + assertEquals(4, gamePin.length(), "Game pin must be exactly 4 characters"); + } + + @Test + void gamePinGeneration_ContainsOnlyValidCharacters() { + assertTrue(gamePin.matches("[A-Z0-9]{4}"), + "Game pin must consist only of uppercase letters (A-Z) and digits (0-9)"); + } + + @Test + void gamePinGeneration_ProducesDistinctPinsForDifferentSessions() { + // Force a collision scenario: register the first pin, then regenerate. + // The service must detect the collision and retry until it finds a free pin. + FakeFirebaseGateway g2 = new FakeFirebaseGateway(); + LobbyService ls = new LobbyService(g2); + + String[] firstPin = {null}; + String[] secondPin = {null}; + + ls.generateAvailableGamePin(new LobbyService.GamePinCallback() { + @Override public void onSuccess(String p) { firstPin[0] = p; } + @Override public void onFailure(Exception e) { fail(e.getMessage()); } + }); + + // Register the first pin by creating a session + g2.createSession(firstPin[0], "hostX", "lobby", 0, 5, + new FirebaseGateway.CreateSessionCallback() { + @Override public void onSuccess(String id) {} + @Override public void onFailure(Exception e) { fail(); } + }); + + ls.generateAvailableGamePin(new LobbyService.GamePinCallback() { + @Override public void onSuccess(String p) { secondPin[0] = p; } + @Override public void onFailure(Exception e) { fail(e.getMessage()); } + }); + + assertNotNull(firstPin[0]); + assertNotNull(secondPin[0]); + assertNotEquals(firstPin[0], secondPin[0], + "A second generated pin must differ from the already-used first pin"); + } + + // ========================================================================= + // REQ: System should validate the entered game pin before allowing access + // ========================================================================= + + @Test + void joinSession_FailsWithNonExistentPin() { + boolean[] failed = {false}; + lobbyService.joinSession("Ghost", "ZZZZ", new LobbyService.JoinSessionCallback() { + @Override public void onSuccess(SessionJoinResult r) { fail("Should not succeed with wrong pin"); } + @Override public void onFailure(Exception e) { failed[0] = true; } + }); + assertTrue(failed[0], "Joining with a pin that does not match any session must fail"); + } + + @Test + void joinSession_FailsWithBlankPin() { + boolean[] failed = {false}; + lobbyService.joinSession("Ghost", " ", new LobbyService.JoinSessionCallback() { + @Override public void onSuccess(SessionJoinResult r) { fail("Should not succeed with blank pin"); } + @Override public void onFailure(Exception e) { failed[0] = true; } + }); + assertTrue(failed[0], "Joining with a blank pin must fail validation"); + } + + @Test + void joinSession_FailsWithTooShortPin() { + boolean[] failed = {false}; + lobbyService.joinSession("Ghost", "AB", new LobbyService.JoinSessionCallback() { + @Override public void onSuccess(SessionJoinResult r) { fail("Should not succeed with short pin"); } + @Override public void onFailure(Exception e) { failed[0] = true; } + }); + assertTrue(failed[0], "Joining with a pin shorter than 4 characters must fail validation"); + } + + // ========================================================================= + // REQ: System should store song title, artist name and audio clip reference + // ========================================================================= + + @Test + void songData_TitleArtistAndPreviewUrlAreStoredAndRetrievable() { + List[] retrieved = new List[1]; + gateway.getQuestions(sessionId, new FirebaseGateway.GetQuestionsCallback() { + @Override public void onSuccess(List q) { retrieved[0] = q; } + @Override public void onFailure(Exception e) { fail("getQuestions failed"); } + }); + + assertEquals(TOTAL_ROUNDS, retrieved[0].size(), + "All " + TOTAL_ROUNDS + " questions must be retrievable from the gateway"); + + for (FirebaseGateway.QuestionData qd : retrieved[0]) { + assertNotNull(qd.songTitle, "Song title must be stored"); + assertFalse(qd.songTitle.isEmpty(), "Song title must not be empty"); + + assertNotNull(qd.songArtist, "Artist name must be stored"); + assertFalse(qd.songArtist.isEmpty(), "Artist name must not be empty"); + + assertNotNull(qd.previewUrl, "Audio clip reference (previewUrl) must be stored"); + assertFalse(qd.previewUrl.isEmpty(), "Audio clip reference must not be empty"); + } + } + + // ========================================================================= + // REQ: System should play a 30-second audio clip at the start of each round + // ========================================================================= + + @Test + void roundDuration_IsExactly30Seconds() { + assertEquals(30f, GameRules.ROUND_DURATION_SECONDS, 0.001f, + "Round duration constant must be exactly 30 seconds"); + } + + @Test + void eachQuestion_HasAnAudioClipReference() { + for (int i = 0; i < questions.size(); i++) { + Song song = questions.get(i).getSong(); + assertNotNull(song.getPreviewUrl(), + "Round " + i + ": song must have a previewUrl for audio playback"); + assertFalse(song.getPreviewUrl().isEmpty(), + "Round " + i + ": previewUrl must not be empty"); + } + } + + // ========================================================================= + // REQ: System should display multiple-choice answer options each round + // ========================================================================= + + @Test + void eachQuestion_HasExactlyFourOptions() { + for (int i = 0; i < questions.size(); i++) { + assertEquals(4, questions.get(i).getOptions().size(), + "Round " + i + " must present exactly 4 answer options"); + } + } + + @Test + void eachQuestion_IncludesCorrectAnswerAmongOptions() { + for (int i = 0; i < questions.size(); i++) { + Question q = questions.get(i); + assertTrue(q.getOptions().contains(q.getCorrectAnswer()), + "Round " + i + ": the correct answer must appear in the displayed options"); + } + } + + // ========================================================================= + // REQ: System should not repeat the same song within a single game session + // ========================================================================= + + @Test + void questions_HaveNoRepeatedSongs() { + Set seenIds = new HashSet<>(); + for (Question q : questions) { + String songId = q.getSong().getId(); + assertTrue(seenIds.add(songId), + "Song '" + q.getSong().getTitle() + "' (id=" + songId + ") must not appear more than once"); + } + assertEquals(TOTAL_ROUNDS, seenIds.size(), + "Each round must feature a distinct song — no repeats across " + TOTAL_ROUNDS + " rounds"); + } + + // ========================================================================= + // REQ: System should randomly select songs for each round + // (Verified indirectly: all rounds use distinct songs, confirming the selection + // mechanism does not pin the same song to every slot.) + // ========================================================================= + + @Test + void questions_AllRoundsUseDifferentSongs() { + Set titles = new HashSet<>(); + for (Question q : questions) titles.add(q.getSong().getTitle()); + assertEquals(TOTAL_ROUNDS, titles.size(), + "Each of the " + TOTAL_ROUNDS + " rounds must use a different song title"); + } + + // ========================================================================= + // REQ: System should evaluate whether the selected answer is correct + // ========================================================================= + + @Test + void correctAnswer_IsRecognisedAsCorrect() { + Question q = questions.get(0); + assertTrue(q.isCorrect(q.getCorrectAnswer()), + "The correct answer must be recognised as correct by Question.isCorrect()"); + } + + @Test + void wrongAnswer_IsRecognisedAsIncorrect() { + Question q = questions.get(0); + String wrong = q.getOptions().stream() + .filter(o -> !o.equals(q.getCorrectAnswer())) + .findFirst() + .orElseThrow(); + assertFalse(q.isCorrect(wrong), + "A wrong answer must not be recognised as correct by Question.isCorrect()"); + } + + // ========================================================================= + // REQ: System should award points for a correct answer + // ========================================================================= + + @Test + void correctAnswer_AwardsPositivePoints() { + int points = ScoreCalculator.calculateScore(true, 10.0); + assertTrue(points > 0, "A correct answer must award a positive number of points"); + } + + @Test + void incorrectAnswer_AwardsZeroPoints() { + int points = ScoreCalculator.calculateScore(false, 5.0); + assertEquals(0, points, "An incorrect answer must award exactly zero points"); + } + + // ========================================================================= + // REQ: If user does not select an answer within the time limit → zero points + // ========================================================================= + + @Test + void noAnswerWithinTimeLimit_AwardsZeroPoints() { + int points = ScoreCalculator.calculateScore(false, GameRules.ROUND_DURATION_SECONDS); + assertEquals(0, points, + "A player who does not answer within the time limit must receive zero points"); + } + + // ========================================================================= + // REQ: Fastest correct answer receives additional bonus points + // ========================================================================= + + @Test + void earlierCorrectAnswer_ScoresHigherThanLaterCorrectAnswer() { + int earlyPoints = ScoreCalculator.calculateScore(true, 2.0); + int latePoints = ScoreCalculator.calculateScore(true, 25.0); + assertTrue(earlyPoints > latePoints, + "A player who answers correctly earlier must outscore a player who answers later"); + } + + @Test + void answerWithinGracePeriod_ReceivesMaximumPoints() { + // Grace period is 3 seconds; t=2.9 s is safely inside it + int points = ScoreCalculator.calculateScore(true, 2.9); + assertEquals(1000, points, + "Answering within the 3-second grace period must award the maximum of 1 000 points"); + } + + @Test + void scoreDecaysLinearlyAcrossDecayWindow() { + // Decay window: 3 s → 30 s (27 s wide). Midpoint = 3 + 13.5 = 16.5 s + // points = 1000 - 700 × (13.5 / 27) = 1000 - 350 = 650 + int points = ScoreCalculator.calculateScore(true, 16.5); + assertEquals(650, points, + "Score at the midpoint of the decay window (t=16.5 s) must be 650"); + } + + // ========================================================================= + // REQ: User should be able to answer before the audio clip has finished + // ========================================================================= + + @Test + void earlyAnswer_BeforeClipEnds_IsValidAndScored() { + // The clip lasts ROUND_DURATION_SECONDS (30 s); t=2 s is well before it ends. + assertTrue(2.0 < GameRules.ROUND_DURATION_SECONDS, + "t=2 s must be before the end of the 30-second clip"); + int points = ScoreCalculator.calculateScore(true, 2.0); + assertEquals(1000, points, + "Answering correctly at t=2 s (before the clip ends) must award maximum points"); + } + + // ========================================================================= + // REQ: User should be able to select one answer option per round + // ========================================================================= + + @Test + void playerCanSelectOneAnswerPerRound() { + gateway.submitAnswer(sessionId, 0, bob.getId(), new FirebaseGateway.SimpleCallback() { + @Override public void onSuccess() {} + @Override public void onFailure(Exception e) { fail(); } + }); + assertTrue(gateway.hasAnswerRecorded(sessionId, 0, bob.getId()), + "Player's answer must be recorded after selection"); + assertEquals(1, gateway.getAnswerCountForRound(sessionId, 0), + "Exactly one answer must be recorded for the player in this round"); + } + + // ========================================================================= + // REQ: System should automatically lock the user's answer once selected + // ========================================================================= + + @Test + void answerLocking_DuplicateSubmissionDoesNotChangeCount() { + // Submit Alice's answer twice for round 0. + // Because the gateway stores answers in a Set keyed by playerId, + // the second call is a no-op — exactly as the real Firestore implementation, + // which uses "{roundIndex}_{playerId}" as the document ID. + FirebaseGateway.SimpleCallback noop = new FirebaseGateway.SimpleCallback() { + @Override public void onSuccess() {} + @Override public void onFailure(Exception e) { fail(); } + }; + gateway.submitAnswer(sessionId, 0, alice.getId(), noop); + gateway.submitAnswer(sessionId, 0, alice.getId(), noop); + + assertEquals(1, gateway.getAnswerCountForRound(sessionId, 0), + "Submitting an answer a second time must not increase the answer count " + + "(the answer is locked after the first selection)"); + } + + // ========================================================================= + // Full end-to-end simulation: 5 players × 5 rounds + // ========================================================================= + + @Test + void fullGameWith5PlayersAnd5Rounds() { + + // ── Pre-game checks ────────────────────────────────────────────────── + assertEquals(TOTAL_PLAYERS, gateway.getPlayerCountInSession(sessionId), + "Lobby must contain exactly " + TOTAL_PLAYERS + " players before the game starts"); + + // Host transitions the session to "in_round" + gateway.updateSessionState(sessionId, FirebaseGateway.STATE_IN_ROUND, + new FirebaseGateway.SimpleCallback() { + @Override public void onSuccess() {} + @Override public void onFailure(Exception e) { fail(); } + }); + assertEquals(FirebaseGateway.STATE_IN_ROUND, gateway.getSessionState(sessionId), + "Session state must be 'in_round' after the host starts the game"); + + int aliceTotal = 0, bobTotal = 0, charlieTotal = 0, dianaTotal = 0, eveTotal = 0; + + for (int round = 0; round < TOTAL_ROUNDS; round++) { + final int r = round; + Question q = questions.get(round); + + // ── REQ: audio clip reference exists for this round ─────────────── + assertNotNull(q.getSong().getPreviewUrl(), + "Round " + r + ": song must have a previewUrl (audio clip reference)"); + assertFalse(q.getSong().getPreviewUrl().isEmpty(), + "Round " + r + ": previewUrl must not be empty"); + + // ── REQ: 4 multiple-choice options are available ────────────────── + assertEquals(4, q.getOptions().size(), + "Round " + r + ": must display exactly 4 answer options"); + assertTrue(q.getOptions().contains(q.getCorrectAnswer()), + "Round " + r + ": correct answer must appear in the displayed options"); + + final String correct = q.getCorrectAnswer(); + final String wrong = q.getOptions().stream() + .filter(o -> !o.equals(correct)) + .findFirst() + .orElseThrow(); + + FirebaseGateway.SimpleCallback noop = new FirebaseGateway.SimpleCallback() { + @Override public void onSuccess() {} + @Override public void onFailure(Exception e) { fail("Gateway call failed in round " + r); } + }; + + // ── Alice: correct at t=2 s ───────────────────────────────────── + // REQ: can answer before the 30-second clip has finished + assertTrue(2.0 < GameRules.ROUND_DURATION_SECONDS, + "Round " + r + ": t=2 s must be before the audio clip ends"); + assertTrue(q.isCorrect(correct), "Round " + r + ": Alice's selected answer must be correct"); + int aliceRound = ScoreCalculator.calculateScore(true, 2.0); + assertEquals(SCORE_AT_T2, aliceRound, + "Round " + r + ": Alice (t=2 s, correct) must receive " + SCORE_AT_T2 + " pts"); + alice.addScore(aliceRound); + aliceTotal += aliceRound; + gateway.updatePlayerScore(sessionId, alice.getId(), aliceTotal, noop); + gateway.submitAnswer(sessionId, round, alice.getId(), noop); + + // REQ: answer is locked — duplicate submission must not change the count + gateway.submitAnswer(sessionId, round, alice.getId(), noop); + assertEquals(1, gateway.getAnswerCountForRound(sessionId, round), + "Round " + r + ": Alice's duplicate submission must not increase the answer count"); + + // ── Bob: correct at t=10 s ────────────────────────────────────── + assertTrue(q.isCorrect(correct), "Round " + r + ": Bob's selected answer must be correct"); + int bobRound = ScoreCalculator.calculateScore(true, 10.0); + assertEquals(SCORE_AT_T10, bobRound, + "Round " + r + ": Bob (t=10 s, correct) must receive " + SCORE_AT_T10 + " pts"); + bob.addScore(bobRound); + bobTotal += bobRound; + gateway.updatePlayerScore(sessionId, bob.getId(), bobTotal, noop); + gateway.submitAnswer(sessionId, round, bob.getId(), noop); + + // ── Charlie: correct at t=25 s ────────────────────────────────── + assertTrue(q.isCorrect(correct), "Round " + r + ": Charlie's selected answer must be correct"); + int charlieRound = ScoreCalculator.calculateScore(true, 25.0); + assertEquals(SCORE_AT_T25, charlieRound, + "Round " + r + ": Charlie (t=25 s, correct) must receive " + SCORE_AT_T25 + " pts"); + charlie.addScore(charlieRound); + charlieTotal += charlieRound; + gateway.updatePlayerScore(sessionId, charlie.getId(), charlieTotal, noop); + gateway.submitAnswer(sessionId, round, charlie.getId(), noop); + + // REQ: fastest correct answer gets the highest score + assertTrue(aliceRound > bobRound, + "Round " + r + ": Alice (t=2 s) must outscore Bob (t=10 s)"); + assertTrue(bobRound > charlieRound, + "Round " + r + ": Bob (t=10 s) must outscore Charlie (t=25 s)"); + + // ── Diana: wrong answer at t=5 s ──────────────────────────────── + assertFalse(q.isCorrect(wrong), "Round " + r + ": Diana's selected answer must be wrong"); + int dianaRound = ScoreCalculator.calculateScore(false, 5.0); + assertEquals(0, dianaRound, + "Round " + r + ": Diana (wrong answer) must receive 0 pts"); + diana.addScore(dianaRound); + // dianaTotal stays 0 + gateway.updatePlayerScore(sessionId, diana.getId(), dianaTotal, noop); + gateway.submitAnswer(sessionId, round, diana.getId(), noop); + + // ── Eve: no answer — timer expires ────────────────────────────── + // REQ: player who does not answer within the time limit receives zero points + int eveRound = ScoreCalculator.calculateScore(false, GameRules.ROUND_DURATION_SECONDS); + assertEquals(0, eveRound, + "Round " + r + ": Eve (no answer / timer expired) must receive 0 pts"); + eve.addScore(eveRound); + // eveTotal stays 0; host submits on her behalf to complete the round + gateway.updatePlayerScore(sessionId, eve.getId(), eveTotal, noop); + gateway.submitAnswer(sessionId, round, eve.getId(), noop); + + // ── All 5 players have answered — round is complete ───────────── + assertEquals(TOTAL_PLAYERS, gateway.getAnswerCountForRound(sessionId, round), + "Round " + r + ": all " + TOTAL_PLAYERS + " players must have submitted before the round closes"); + + // Transition to the next round (host drives this) + if (round < TOTAL_ROUNDS - 1) { + gateway.updateRoundState(sessionId, FirebaseGateway.STATE_IN_ROUND, round + 1, noop); + } + } + + // ── Post-game: transition to GAME_OVER ─────────────────────────────── + gateway.updateSessionState(sessionId, FirebaseGateway.STATE_GAME_OVER, + new FirebaseGateway.SimpleCallback() { + @Override public void onSuccess() {} + @Override public void onFailure(Exception e) { fail(); } + }); + assertEquals(FirebaseGateway.STATE_GAME_OVER, gateway.getSessionState(sessionId), + "Session state must be 'game_over' after the final round"); + + // ── Cumulative score assertions ─────────────────────────────────────── + assertEquals(5 * SCORE_AT_T2, aliceTotal, "Alice: 5 × 1000 = 5000"); + assertEquals(5 * SCORE_AT_T10, bobTotal, "Bob: 5 × 819 = 4095"); + assertEquals(5 * SCORE_AT_T25, charlieTotal, "Charlie: 5 × 430 = 2150"); + assertEquals(0, dianaTotal, "Diana: 0 pts (all answers wrong)"); + assertEquals(0, eveTotal, "Eve: 0 pts (never answered)"); + + // Confirm scores were persisted in the gateway + assertEquals(aliceTotal, gateway.getPlayerScoreInGateway(sessionId, alice.getId()), "Alice's persisted score"); + assertEquals(bobTotal, gateway.getPlayerScoreInGateway(sessionId, bob.getId()), "Bob's persisted score"); + assertEquals(charlieTotal, gateway.getPlayerScoreInGateway(sessionId, charlie.getId()), "Charlie's persisted score"); + assertEquals(0, gateway.getPlayerScoreInGateway(sessionId, diana.getId()), "Diana's persisted score"); + assertEquals(0, gateway.getPlayerScoreInGateway(sessionId, eve.getId()), "Eve's persisted score"); + + // ── Leaderboard ranking ─────────────────────────────────────────────── + Leaderboard leaderboard = new Leaderboard(List.of(alice, bob, charlie, diana, eve)); + List entries = leaderboard.getEntries(); + + assertEquals(TOTAL_PLAYERS, entries.size(), "Leaderboard must contain all 5 players"); + + assertEquals("Alice", entries.get(0).getPlayerName(), "Rank 1 must be Alice"); + assertEquals("Bob", entries.get(1).getPlayerName(), "Rank 2 must be Bob"); + assertEquals("Charlie", entries.get(2).getPlayerName(), "Rank 3 must be Charlie"); + + assertEquals(1, entries.get(0).getRank(), "Alice must hold rank 1"); + assertEquals(2, entries.get(1).getRank(), "Bob must hold rank 2"); + assertEquals(3, entries.get(2).getRank(), "Charlie must hold rank 3"); + + // Diana and Eve are both on 0 pts; their relative order is undefined — verify both appear in bottom two + Set bottomTwo = new HashSet<>(Arrays.asList( + entries.get(3).getPlayerName(), + entries.get(4).getPlayerName() + )); + assertTrue(bottomTwo.contains("Diana"), "Diana must appear in the bottom two (0 pts)"); + assertTrue(bottomTwo.contains("Eve"), "Eve must appear in the bottom two (0 pts)"); + + // ── No song repeated across rounds ──────────────────────────────────── + Set songIds = new HashSet<>(); + for (Question q : questions) { + assertTrue(songIds.add(q.getSong().getId()), + "Song '" + q.getSong().getTitle() + "' must not repeat within the session"); + } + assertEquals(TOTAL_ROUNDS, songIds.size(), + "All " + TOTAL_ROUNDS + " rounds must use distinct songs"); + } +} diff --git a/core/src/test/java/group07/beatbattle/firebase/FakeFirebaseGateway.java b/core/src/test/java/group07/beatbattle/firebase/FakeFirebaseGateway.java new file mode 100644 index 0000000..3279fff --- /dev/null +++ b/core/src/test/java/group07/beatbattle/firebase/FakeFirebaseGateway.java @@ -0,0 +1,318 @@ +package group07.beatbattle.firebase; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import group07.beatbattle.model.Player; + +/** + * In-memory implementation of FirebaseGateway for unit and integration tests. + * + * All callbacks are fired synchronously so tests do not require async plumbing. + * Answers are stored in a Set per (sessionId, roundIndex, playerId) triple, which + * mirrors the idempotent document-ID scheme used by the real Firestore implementation + * and naturally enforces the "one answer per player per round" invariant. + */ +public class FakeFirebaseGateway implements FirebaseGateway { + + // ------------------------------------------------------------------------- + // Storage + // ------------------------------------------------------------------------- + + private int sessionCounter = 0; + + private final Map sessions = new LinkedHashMap<>(); + private final Map pinToSession = new HashMap<>(); + private final Map> players = new LinkedHashMap<>(); + private final Map> questions = new LinkedHashMap<>(); + /** answers.get(sessionId).get(roundIndex) = set of playerIds that answered */ + private final Map>> answers = new LinkedHashMap<>(); + + // ------------------------------------------------------------------------- + // Listeners (fired synchronously on each mutation) + // ------------------------------------------------------------------------- + + private final Map playerListeners = new HashMap<>(); + private final Map stateListeners = new HashMap<>(); + private final Map> answerCountListeners = new HashMap<>(); + + // ------------------------------------------------------------------------- + // Internal DTOs + // ------------------------------------------------------------------------- + + private static class SessionData { + String gamePin; + String hostId; + String state; + int currentRound; + int totalRounds; + String endReason = ""; + } + + private static class PlayerData { + String name; + int score; + boolean isHost; + } + + // ========================================================================= + // FirebaseGateway implementation + // ========================================================================= + + @Override + public void gamePinExists(String gamePin, GamePinExistsCallback callback) { + callback.onResult(pinToSession.containsKey(gamePin)); + } + + @Override + public void createSession(String gamePin, String hostId, String state, + int currentRound, int totalRounds, + CreateSessionCallback callback) { + String sessionId = "session-" + (++sessionCounter); + SessionData data = new SessionData(); + data.gamePin = gamePin; + data.hostId = hostId; + data.state = state; + data.currentRound = currentRound; + data.totalRounds = totalRounds; + sessions.put(sessionId, data); + pinToSession.put(gamePin, sessionId); + players.put(sessionId, new LinkedHashMap<>()); + answers.put(sessionId, new LinkedHashMap<>()); + callback.onSuccess(sessionId); + } + + @Override + public void addHostToPlayers(String sessionId, String hostId, String hostName, + AddHostCallback callback) { + PlayerData p = new PlayerData(); + p.name = hostName; + p.score = 0; + p.isHost = true; + players.get(sessionId).put(hostId, p); + notifyPlayerListeners(sessionId); + callback.onSuccess(); + } + + @Override + public void findSessionIdByGamePin(String gamePin, FindSessionCallback callback) { + String sessionId = pinToSession.get(gamePin); + if (sessionId != null) { + callback.onSuccess(sessionId); + } else { + callback.onFailure(new IllegalArgumentException("No session found for pin: " + gamePin)); + } + } + + @Override + public void addPlayerToSession(String sessionId, String playerId, String playerName, + AddPlayerCallback callback) { + PlayerData p = new PlayerData(); + p.name = playerName; + p.score = 0; + p.isHost = false; + players.get(sessionId).put(playerId, p); + notifyPlayerListeners(sessionId); + callback.onSuccess(); + } + + @Override + public void listenToPlayers(String sessionId, PlayersListenerCallback callback) { + playerListeners.put(sessionId, callback); + callback.onPlayersChanged(buildPlayerList(sessionId)); + } + + @Override + public void fetchPlayers(String sessionId, PlayersListenerCallback callback) { + callback.onPlayersChanged(buildPlayerList(sessionId)); + } + + @Override + public void removePlayerFromSession(String sessionId, String playerId, + RemovePlayerCallback callback) { + if (players.containsKey(sessionId)) { + players.get(sessionId).remove(playerId); + notifyPlayerListeners(sessionId); + } + callback.onSuccess(); + } + + @Override + public void cancelSession(String sessionId, String endReason, SimpleCallback callback) { + SessionData data = sessions.get(sessionId); + if (data != null) { + data.state = STATE_CANCELLED; + data.endReason = endReason; + notifyStateListeners(sessionId); + } + callback.onSuccess(); + } + + @Override + public void deleteSession(String sessionId, DeleteSessionCallback callback) { + sessions.remove(sessionId); + pinToSession.values().removeIf(v -> v.equals(sessionId)); + players.remove(sessionId); + questions.remove(sessionId); + answers.remove(sessionId); + callback.onSuccess(); + } + + @Override + public void storeQuestions(String sessionId, List questionList, + SimpleCallback callback) { + questions.put(sessionId, new ArrayList<>(questionList)); + callback.onSuccess(); + } + + @Override + public void getQuestions(String sessionId, GetQuestionsCallback callback) { + List q = questions.get(sessionId); + callback.onSuccess(q != null ? new ArrayList<>(q) : new ArrayList<>()); + } + + @Override + public void updateSessionState(String sessionId, String state, SimpleCallback callback) { + SessionData data = sessions.get(sessionId); + if (data != null) { + data.state = state; + notifyStateListeners(sessionId); + } + callback.onSuccess(); + } + + @Override + public void updateRoundState(String sessionId, String state, int roundIndex, + SimpleCallback callback) { + SessionData data = sessions.get(sessionId); + if (data != null) { + data.state = state; + data.currentRound = roundIndex; + notifyStateListeners(sessionId); + } + callback.onSuccess(); + } + + @Override + public void listenToSessionState(String sessionId, SessionStateListener listener) { + stateListeners.put(sessionId, listener); + SessionData data = sessions.get(sessionId); + if (data != null) { + listener.onStateChanged(data.state, data.currentRound, data.endReason); + } + } + + @Override + public void stopListeningToSessionState(String sessionId) { + stateListeners.remove(sessionId); + } + + @Override + public void updatePlayerScore(String sessionId, String playerId, int score, + SimpleCallback callback) { + Map sessionPlayers = players.get(sessionId); + if (sessionPlayers != null && sessionPlayers.containsKey(playerId)) { + sessionPlayers.get(playerId).score = score; + } + callback.onSuccess(); + } + + @Override + public void submitAnswer(String sessionId, int roundIndex, String playerId, + SimpleCallback callback) { + // Set.add is a no-op for duplicates — mirrors idempotent Firestore doc-ID behaviour. + answers.computeIfAbsent(sessionId, k -> new LinkedHashMap<>()) + .computeIfAbsent(roundIndex, k -> new LinkedHashSet<>()) + .add(playerId); + notifyAnswerCountListeners(sessionId, roundIndex); + callback.onSuccess(); + } + + @Override + public void listenToRoundAnswerCount(String sessionId, int roundIndex, + AnswerCountCallback callback) { + answerCountListeners + .computeIfAbsent(sessionId, k -> new LinkedHashMap<>()) + .put(roundIndex, callback); + callback.onCountChanged(getAnswerCount(sessionId, roundIndex)); + } + + // ========================================================================= + // Internal helpers + // ========================================================================= + + private List buildPlayerList(String sessionId) { + List result = new ArrayList<>(); + Map sessionPlayers = players.get(sessionId); + if (sessionPlayers == null) return result; + for (Map.Entry entry : sessionPlayers.entrySet()) { + Player p = new Player(entry.getKey(), entry.getValue().name, entry.getValue().isHost); + p.setScore(entry.getValue().score); + result.add(p); + } + return result; + } + + private void notifyPlayerListeners(String sessionId) { + PlayersListenerCallback cb = playerListeners.get(sessionId); + if (cb != null) cb.onPlayersChanged(buildPlayerList(sessionId)); + } + + private void notifyStateListeners(String sessionId) { + SessionStateListener listener = stateListeners.get(sessionId); + SessionData data = sessions.get(sessionId); + if (listener != null && data != null) { + listener.onStateChanged(data.state, data.currentRound, data.endReason); + } + } + + private void notifyAnswerCountListeners(String sessionId, int roundIndex) { + Map listeners = answerCountListeners.get(sessionId); + if (listeners == null) return; + AnswerCountCallback cb = listeners.get(roundIndex); + if (cb != null) cb.onCountChanged(getAnswerCount(sessionId, roundIndex)); + } + + private int getAnswerCount(String sessionId, int roundIndex) { + Map> sessionAnswers = answers.get(sessionId); + if (sessionAnswers == null) return 0; + Set roundAnswers = sessionAnswers.get(roundIndex); + return roundAnswers == null ? 0 : roundAnswers.size(); + } + + // ========================================================================= + // Test-inspection helpers (not part of the interface) + // ========================================================================= + + public String getSessionState(String sessionId) { + SessionData data = sessions.get(sessionId); + return data != null ? data.state : null; + } + + public int getPlayerScoreInGateway(String sessionId, String playerId) { + Map sessionPlayers = players.get(sessionId); + if (sessionPlayers == null || !sessionPlayers.containsKey(playerId)) return -1; + return sessionPlayers.get(playerId).score; + } + + public int getAnswerCountForRound(String sessionId, int roundIndex) { + return getAnswerCount(sessionId, roundIndex); + } + + public boolean hasAnswerRecorded(String sessionId, int roundIndex, String playerId) { + Map> sessionAnswers = answers.get(sessionId); + if (sessionAnswers == null) return false; + Set roundAnswers = sessionAnswers.get(roundIndex); + return roundAnswers != null && roundAnswers.contains(playerId); + } + + public int getPlayerCountInSession(String sessionId) { + Map sessionPlayers = players.get(sessionId); + return sessionPlayers == null ? 0 : sessionPlayers.size(); + } +}