mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-04-28 08:20:44 +00:00
3be799d68a
Followed code-reviewer audit. Findings applied: - twentyq/README.md, twentyq/index.js header — claimed "function calling" + ANSWER_FUNCTION_SCHEMA / submit_answer; rewrite to JSON-in-content matching what the code actually does. Added generateRoundStart line. - wrangler.toml [ai] comment — list both bge-m3 (semantle/doantu) AND gemma-4 (twentyq) consumers; drop neuron math that no longer matched. - scripts/stub-kv.js — drop reference to nonexistent REGISTER_DRYRUN flag. - twentyq/ai-client.redactSecret — strip dead "if (out.length > 0)" branch (String.replace cannot produce empty string from the inputs we pass). - handlers.test.js — drop noise saveGame() before "no games" stats assert; add ai.run call-count guards on two-AI-call flows. - docs/codebase-summary.md — full rewrite of Active Modules table (semantle/doantu/lolschedule/twentyq were missing); fix vitest 2→4 + wrangler 3→4 versions; replace stale 200-test count with current ~450. - docs/architecture.md — file tree includes lolschedule/semantle/doantu/ twentyq + cron-dispatcher + sql-store* + scripts/migrate.js; moduleRegistry snippet matches src/modules/index.js. - docs/todo.md — entire file obsolete (D1 UUID populated, cron live). Deleted. Tests: 449 pass, lint clean.
202 lines
7.6 KiB
JavaScript
202 lines
7.6 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, mockRoundStart } 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 () => {
|
|
mockRoundStart(ai, { category: "instrument", initialHint: "cryptic opener" });
|
|
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([]);
|
|
expect(game.category).toBe("instrument");
|
|
expect(game.initialHint).toBe("cryptic opener");
|
|
});
|
|
|
|
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 }));
|
|
mockRoundStart(ai);
|
|
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 () => {
|
|
mockRoundStart(ai);
|
|
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(ai.run).toHaveBeenCalledTimes(2); // roundstart + judge
|
|
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 roundstart UpstreamError as friendly message", async () => {
|
|
mockFailure(ai, new Error("roundstart down"));
|
|
const ctx = makeCtx(1, "private", "/twentyq");
|
|
await handleTwentyq(ctx, { db, env });
|
|
expect(ctx.replies[0].text).toMatch(/hiccup|try again/i);
|
|
expect(await loadGame(db, 1)).toBeNull();
|
|
});
|
|
|
|
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 () => {
|
|
mockRoundStart(ai);
|
|
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 });
|
|
expect(ai.run).toHaveBeenCalledTimes(2); // roundstart + judge
|
|
// 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 empty-stats message when no rounds finished", async () => {
|
|
const ctx = makeCtx(1);
|
|
await handleStats(ctx, { db });
|
|
expect(ctx.replies[0].text).toMatch(/no.*games/i);
|
|
});
|
|
});
|
|
});
|