import { describe, it, expect, vi, beforeEach } from "vitest"; import type { GameRequest, AnswerSubmission } from "@lila/shared"; vi.mock("@lila/db", () => ({ getGameTerms: vi.fn(), getDistractors: vi.fn() })); import { getGameTerms, getDistractors } from "@lila/db"; import { createGameSession, evaluateAnswer } from "./gameService.js"; import { InMemoryGameSessionStore } from "../gameSessionStore/index.js"; const mockGetGameTerms = vi.mocked(getGameTerms); const mockGetDistractors = vi.mocked(getDistractors); const validRequest: GameRequest = { source_language: "en", target_language: "it", pos: "noun", difficulty: "easy", rounds: 3, }; const fakeTerms = [ { termId: "t1", sourceText: "dog", targetText: "cane", sourceGloss: null }, { termId: "t2", sourceText: "cat", targetText: "gatto", sourceGloss: null }, { termId: "t3", sourceText: "house", targetText: "casa", sourceGloss: "a building for living in", }, ]; beforeEach(() => { vi.clearAllMocks(); mockGetGameTerms.mockResolvedValue(fakeTerms); mockGetDistractors.mockResolvedValue(["wrong1", "wrong2", "wrong3"]); }); describe("createGameSession", () => { let store: InMemoryGameSessionStore; beforeEach(() => { store = new InMemoryGameSessionStore(); }); it("returns a session with the correct number of questions", async () => { const session = await createGameSession(validRequest, store, "user-1"); expect(session.sessionId).toBeDefined(); expect(session.questions).toHaveLength(3); }); it("each question has exactly 4 options", async () => { const session = await createGameSession(validRequest, store, "user-1"); for (const question of session.questions) { expect(question.options).toHaveLength(4); } }); it("each question has a unique questionId", async () => { const session = await createGameSession(validRequest, store, "user-1"); const ids = session.questions.map((q) => q.questionId); expect(new Set(ids).size).toBe(ids.length); }); it("options have sequential optionIds 0-3", async () => { const session = await createGameSession(validRequest, store, "user-1"); for (const question of session.questions) { const optionIds = question.options.map((o) => o.optionId); expect(optionIds).toEqual([0, 1, 2, 3]); } }); it("the correct answer is always among the options", async () => { const session = await createGameSession(validRequest, store, "user-1"); for (let i = 0; i < session.questions.length; i++) { const question = session.questions[i]!; const correctText = fakeTerms[i]!.targetText; const optionTexts = question.options.map((o) => o.text); expect(optionTexts).toContain(correctText); } }); it("correct answer appears exactly once even if getDistractors returns a duplicate", async () => { mockGetDistractors.mockResolvedValueOnce([ "cane", "wrong2", "wrong3", "wrong4", "wrong5", "wrong6", ]); const session = await createGameSession(validRequest, store, "user-1"); const question = session.questions[0]!; const optionTexts = question.options.map((o) => o.text); expect(optionTexts.filter((t) => t === "cane")).toHaveLength(1); expect(question.options).toHaveLength(4); }); it("sets the prompt from the source text", async () => { const session = await createGameSession(validRequest, store, "user-1"); expect(session.questions[0]!.prompt).toBe("dog"); expect(session.questions[1]!.prompt).toBe("cat"); expect(session.questions[2]!.prompt).toBe("house"); }); it("passes gloss through (null or string)", async () => { const session = await createGameSession(validRequest, store, "user-1"); expect(session.questions[0]!.gloss).toBeNull(); expect(session.questions[2]!.gloss).toBe("a building for living in"); }); it("calls getGameTerms with the correct arguments", async () => { await createGameSession(validRequest, store, "user-1"); expect(mockGetGameTerms).toHaveBeenCalledWith( "en", "it", "noun", "easy", 3, ); }); it("calls getDistractors once per question", async () => { await createGameSession(validRequest, store, "user-1"); expect(mockGetDistractors).toHaveBeenCalledTimes(3); }); it("propagates unexpected errors from getGameTerms", async () => { mockGetGameTerms.mockRejectedValue(new Error("connection refused")); await expect( createGameSession(validRequest, store, "user-1"), ).rejects.toThrow("connection refused"); }); it("propagates getDistractors failure", async () => { mockGetDistractors.mockRejectedValue(new Error("db timeout")); await expect( createGameSession(validRequest, store, "user-1"), ).rejects.toThrow("db timeout"); }); }); describe("evaluateAnswer", () => { let store: InMemoryGameSessionStore; beforeEach(() => { store = new InMemoryGameSessionStore(); }); it("returns isCorrect: true when the correct option is selected", async () => { const session = await createGameSession(validRequest, store, "user-1"); const question = session.questions[0]!; const correctText = fakeTerms[0]!.targetText; const correctOption = question.options.find((o) => o.text === correctText)!; const result = await evaluateAnswer( { sessionId: session.sessionId, questionId: question.questionId, selectedOptionId: correctOption.optionId, }, store, "user-1", ); expect(result.isCorrect).toBe(true); expect(result.correctOptionId).toBe(correctOption.optionId); expect(result.selectedOptionId).toBe(correctOption.optionId); }); it("returns isCorrect: false when a wrong option is selected", async () => { const session = await createGameSession(validRequest, store, "user-1"); const question = session.questions[0]!; const correctText = fakeTerms[0]!.targetText; const correctOption = question.options.find((o) => o.text === correctText)!; const wrongOption = question.options.find((o) => o.text !== correctText)!; const result = await evaluateAnswer( { sessionId: session.sessionId, questionId: question.questionId, selectedOptionId: wrongOption.optionId, }, store, "user-1", ); expect(result.isCorrect).toBe(false); expect(result.correctOptionId).toBe(correctOption.optionId); expect(result.selectedOptionId).toBe(wrongOption.optionId); }); it("throws NotFoundError for a non-existent session", async () => { const submission: AnswerSubmission = { sessionId: "00000000-0000-0000-0000-000000000000", questionId: "00000000-0000-0000-0000-000000000000", selectedOptionId: 0, }; await expect(evaluateAnswer(submission, store, "user-1")).rejects.toThrow( "Game session not found", ); }); it("throws NotFoundError for a non-existent question", async () => { const session = await createGameSession(validRequest, store, "user-1"); const submission: AnswerSubmission = { sessionId: session.sessionId, questionId: "00000000-0000-0000-0000-000000000000", selectedOptionId: 0, }; await expect(evaluateAnswer(submission, store, "user-1")).rejects.toThrow( "Question not found", ); }); it("throws NotFoundError when the same question is submitted twice", async () => { const session = await createGameSession(validRequest, store, "user-1"); const question = session.questions[0]!; await evaluateAnswer( { sessionId: session.sessionId, questionId: question.questionId, selectedOptionId: 0, }, store, "user-1", ); await expect( evaluateAnswer( { sessionId: session.sessionId, questionId: question.questionId, selectedOptionId: 0, }, store, "user-1", ), ).rejects.toThrow("Question not found"); }); it("deletes the session after the last question is answered", async () => { const session = await createGameSession(validRequest, store, "user-1"); for (const question of session.questions) { await evaluateAnswer( { sessionId: session.sessionId, questionId: question.questionId, selectedOptionId: 0, }, store, "user-1", ); } await expect( evaluateAnswer( { sessionId: session.sessionId, questionId: session.questions[0]!.questionId, selectedOptionId: 0, }, store, "user-1", ), ).rejects.toThrow("Game session not found"); }); it("throws NotFoundError when getGameTerms returns no terms", async () => { mockGetGameTerms.mockResolvedValue([]); await expect( createGameSession(validRequest, store, "user-1"), ).rejects.toThrow("No terms found"); }); });