- joinLobby: addPlayer returns falsy (race condition fallback) - joinLobby: lobby disappears between addPlayer and final fetch - createLobby: non-unique-violation errors re-thrown immediately - createGameSession: unexpected DB errors propagate correctly
200 lines
6.3 KiB
TypeScript
200 lines
6.3 KiB
TypeScript
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";
|
|
|
|
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", () => {
|
|
it("returns a session with the correct number of questions", async () => {
|
|
const session = await createGameSession(validRequest);
|
|
|
|
expect(session.sessionId).toBeDefined();
|
|
expect(session.questions).toHaveLength(3);
|
|
});
|
|
|
|
it("each question has exactly 4 options", async () => {
|
|
const session = await createGameSession(validRequest);
|
|
|
|
for (const question of session.questions) {
|
|
expect(question.options).toHaveLength(4);
|
|
}
|
|
});
|
|
|
|
it("each question has a unique questionId", async () => {
|
|
const session = await createGameSession(validRequest);
|
|
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);
|
|
|
|
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);
|
|
|
|
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("distractors are never the correct answer", async () => {
|
|
const session = await createGameSession(validRequest);
|
|
|
|
for (let i = 0; i < session.questions.length; i++) {
|
|
const question = session.questions[i]!;
|
|
const correctText = fakeTerms[i]!.targetText;
|
|
const distractorTexts = question.options
|
|
.map((o) => o.text)
|
|
.filter((t) => t !== correctText);
|
|
|
|
for (const text of distractorTexts) {
|
|
expect(text).not.toBe(correctText);
|
|
}
|
|
}
|
|
});
|
|
|
|
it("sets the prompt from the source text", async () => {
|
|
const session = await createGameSession(validRequest);
|
|
|
|
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);
|
|
|
|
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);
|
|
|
|
expect(mockGetGameTerms).toHaveBeenCalledWith(
|
|
"en",
|
|
"it",
|
|
"noun",
|
|
"easy",
|
|
3,
|
|
);
|
|
});
|
|
|
|
it("calls getDistractors once per question", async () => {
|
|
await createGameSession(validRequest);
|
|
|
|
expect(mockGetDistractors).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
it("propagates unexpected errors from getGameTerms", async () => {
|
|
mockGetGameTerms.mockRejectedValue(new Error("connection refused"));
|
|
|
|
await expect(createGameSession(validRequest)).rejects.toThrow(
|
|
"connection refused",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("evaluateAnswer", () => {
|
|
it("returns isCorrect: true when the correct option is selected", async () => {
|
|
const session = await createGameSession(validRequest);
|
|
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,
|
|
});
|
|
|
|
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);
|
|
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,
|
|
});
|
|
|
|
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)).rejects.toThrow(
|
|
"Game session not found",
|
|
);
|
|
});
|
|
|
|
it("throws NotFoundError for a non-existent question", async () => {
|
|
const session = await createGameSession(validRequest);
|
|
|
|
const submission: AnswerSubmission = {
|
|
sessionId: session.sessionId,
|
|
questionId: "00000000-0000-0000-0000-000000000000",
|
|
selectedOptionId: 0,
|
|
};
|
|
|
|
await expect(evaluateAnswer(submission)).rejects.toThrow(
|
|
"Question not found",
|
|
);
|
|
});
|
|
});
|