Files
miti99bot/tests/modules/twentyq/handlers.test.js
T
tiennm99 5b12650906 feat(twentyq): add reverse-Akinator yes/no game module powered by Workers AI
- seeded 54 objects across 6 categories (instrument, animal, food, vehicle, sport, household)
- @cf/google/gemma-4-26b-a4b-it judges via function calling; returns {is_guess, answer, hint}
- pre-AI validator rejects open-ended questions; handler dedups exact repeats
- secret-redacting hint filter as defense-in-depth
- 86 new vitest tests (seeds, state, validator, ai-client, handlers, render)
2026-04-24 14:37:23 +07:00

188 lines
6.9 KiB
JavaScript

import { beforeEach, describe, expect, it, vi } from "vitest";
import { createStore } from "../../../src/db/create-store.js";
import { handleGiveup, handleStats, handleTwentyq } from "../../../src/modules/twentyq/handlers.js";
import { loadGame, loadStats, saveGame } from "../../../src/modules/twentyq/state.js";
import { makeFakeAi, mockFailure, mockJudgement } from "../../fakes/fake-ai.js";
import { makeFakeKv } from "../../fakes/fake-kv-namespace.js";
function makeCtx(userId = 1, chatType = "private", msgText = "/twentyq") {
const replies = [];
return {
chat: { id: userId, type: chatType },
from: { id: userId },
message: { text: msgText },
reply: vi.fn((text, opts) => {
replies.push({ text, opts });
return Promise.resolve();
}),
replies,
};
}
const sampleGame = (overrides = {}) => ({
category: "instrument",
target: "organ",
initialHint: "uses wind through pipes",
startedAt: 1,
solved: false,
turns: [],
...overrides,
});
describe("twentyq/handlers", () => {
/** @type {import("../../../src/db/kv-store-interface.js").KVStore} */
let db;
let ai;
let env;
beforeEach(() => {
db = createStore("twentyq", { KV: makeFakeKv() });
ai = makeFakeAi();
env = { AI: ai };
});
describe("handleTwentyq", () => {
it("starts a fresh round and shows intro when no game + no arg", async () => {
const ctx = makeCtx(1, "private", "/twentyq");
await handleTwentyq(ctx, { db, env });
expect(ctx.reply).toHaveBeenCalledOnce();
expect(ctx.replies[0].text).toMatch(/I'm thinking/);
const game = await loadGame(db, 1);
expect(game).not.toBeNull();
expect(game.turns).toEqual([]);
});
it("shows board when game exists and no arg", async () => {
await saveGame(db, 1, sampleGame());
const ctx = makeCtx(1, "private", "/twentyq");
await handleTwentyq(ctx, { db, env });
expect(ctx.replies[0].text).toMatch(/Category|Initial hint/);
});
it("processes a yes-answer turn", async () => {
await saveGame(db, 1, sampleGame());
mockJudgement(ai, { is_guess: false, answer: "yes", hint: "very large" });
const ctx = makeCtx(1, "private", "/twentyq is it big?");
await handleTwentyq(ctx, { db, env });
expect(ai.run).toHaveBeenCalledOnce();
expect(ctx.replies[0].text).toMatch(/Yes/);
expect(ctx.replies[0].text).toContain("very large");
const game = await loadGame(db, 1);
expect(game.turns).toHaveLength(1);
});
it("ends round on correct guess (is_guess && yes)", async () => {
await saveGame(db, 1, sampleGame());
mockJudgement(ai, { is_guess: true, answer: "yes", hint: "—" });
const ctx = makeCtx(1, "private", "/twentyq is it an organ?");
await handleTwentyq(ctx, { db, env });
expect(ctx.replies[0].text).toContain("Correct");
expect(ctx.replies[0].text).toContain("organ");
// Game cleared
expect(await loadGame(db, 1)).toBeNull();
// Stats updated
const stats = await loadStats(db, 1);
expect(stats.solved).toBe(1);
expect(stats.played).toBe(1);
expect(stats.bestTurnCount).toBe(1);
});
it("treats wrong guess (is_guess && no) as a normal hint turn", async () => {
await saveGame(db, 1, sampleGame());
mockJudgement(ai, { is_guess: true, answer: "no", hint: "metal pipes" });
const ctx = makeCtx(1, "private", "/twentyq is it a piano?");
await handleTwentyq(ctx, { db, env });
expect(ctx.replies[0].text).toMatch(/Not quite|No/);
expect(ctx.replies[0].text).toContain("metal pipes");
expect(await loadGame(db, 1)).not.toBeNull();
});
it("rejects open-ended question without hitting AI", async () => {
await saveGame(db, 1, sampleGame());
const ctx = makeCtx(1, "private", "/twentyq what is it?");
await handleTwentyq(ctx, { db, env });
expect(ai.run).not.toHaveBeenCalled();
expect(ctx.replies[0].text).toMatch(/yes\/no/i);
const game = await loadGame(db, 1);
expect(game.turns).toHaveLength(0);
});
it("dedups exact repeat questions without hitting AI", async () => {
await saveGame(
db,
1,
sampleGame({
turns: [{ text: "is it big?", isGuess: false, answer: "yes", hint: "x", ts: 1 }],
}),
);
const ctx = makeCtx(1, "private", "/twentyq Is It Big?");
await handleTwentyq(ctx, { db, env });
expect(ai.run).not.toHaveBeenCalled();
expect(ctx.replies[0].text).toMatch(/already asked/i);
});
it("starts a fresh round transparently after a solved game", async () => {
await saveGame(db, 1, sampleGame({ solved: true }));
const ctx = makeCtx(1, "private", "/twentyq");
await handleTwentyq(ctx, { db, env });
expect(ctx.replies[0].text).toMatch(/I'm thinking/);
});
it("starts round and processes turn when /twentyq <text> with no prior game", async () => {
mockJudgement(ai, { is_guess: false, answer: "yes", hint: "yes hint" });
const ctx = makeCtx(1, "private", "/twentyq is it big?");
await handleTwentyq(ctx, { db, env });
expect(ctx.reply).toHaveBeenCalledTimes(2); // intro + turn
expect(ctx.replies[0].text).toMatch(/I'm thinking/);
expect(ctx.replies[1].text).toMatch(/Yes/);
});
it("surfaces UpstreamError as friendly message", async () => {
await saveGame(db, 1, sampleGame());
mockFailure(ai, new Error("boom"));
const ctx = makeCtx(1, "private", "/twentyq is it big?");
await handleTwentyq(ctx, { db, env });
expect(ctx.replies[0].text).toMatch(/hiccup|try again/i);
});
it("uses chat id as subject in groups", async () => {
mockJudgement(ai, { is_guess: false, answer: "no", hint: "h" });
const ctx = makeCtx(99, "group", "/twentyq is it big?");
ctx.chat.id = 12345;
await handleTwentyq(ctx, { db, env });
// Game saved under chat id (12345), not user id (99)
expect(await loadGame(db, 12345)).not.toBeNull();
expect(await loadGame(db, 99)).toBeNull();
});
});
describe("handleGiveup", () => {
it("replies 'no active round' when none", async () => {
const ctx = makeCtx(1);
await handleGiveup(ctx, { db });
expect(ctx.replies[0].text).toMatch(/no active round/i);
});
it("reveals target + records loss + clears game", async () => {
await saveGame(db, 1, sampleGame());
const ctx = makeCtx(1);
await handleGiveup(ctx, { db });
expect(ctx.replies[0].text).toContain("organ");
expect(await loadGame(db, 1)).toBeNull();
const stats = await loadStats(db, 1);
expect(stats.played).toBe(1);
expect(stats.solved).toBe(0);
});
});
describe("handleStats", () => {
it("renders stats summary", async () => {
await saveGame(db, 1, sampleGame());
const ctx = makeCtx(1);
await handleStats(ctx, { db });
// No games played yet -> "no twentyq games"
expect(ctx.replies[0].text).toMatch(/no.*games/i);
});
});
});