mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-04-27 20:20:32 +00:00
fd5a1d2903
BGE embeddings occupy a narrow cone in vector space, so raw cosine of two unrelated words already sits at ~0.40-0.55. Displaying `raw * 100` made every random guess read as 40-70% warm, which defeated the warmth UX. format.js now applies a normalized sigmoid (FLOOR 0.40, CENTER 0.60, SCALE 8) to remap raw cosine → displayed 0-100. Unrelated pairs drop to ≤30, loose relation lands around 40-55, clear synonyms hit 85+, and exact match stays at 100. Emoji buckets were rebased onto the calibrated score; formatWarmth lost its sign column (calibrated output is always non-negative). render.js rounds once and feeds the integer to both formatWarmth and warmthEmoji so the display value and bucket stay in sync. Constants are empirical — retune if swapping to a non-BGE model.
549 lines
18 KiB
JavaScript
549 lines
18 KiB
JavaScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { createStore } from "../../../src/db/create-store.js";
|
|
import { UpstreamError } from "../../../src/modules/semantle/api-client.js";
|
|
import {
|
|
handleGiveup,
|
|
handleSemantle,
|
|
handleStats,
|
|
} from "../../../src/modules/semantle/handlers.js";
|
|
import { makeFakeKv } from "../../fakes/fake-kv-namespace.js";
|
|
|
|
function makeCtx(userId = 1, chatType = "private", msgText = "/semantle") {
|
|
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,
|
|
};
|
|
}
|
|
|
|
function makeClient() {
|
|
return {
|
|
randomWord: vi.fn(),
|
|
similarity: vi.fn(),
|
|
};
|
|
}
|
|
|
|
describe("semantle/handlers", () => {
|
|
let db;
|
|
let client;
|
|
|
|
beforeEach(() => {
|
|
db = createStore("semantle", { KV: makeFakeKv() });
|
|
client = makeClient();
|
|
});
|
|
|
|
describe("handleSemantle", () => {
|
|
it("starts a new round when no args", async () => {
|
|
client.randomWord.mockResolvedValue({ word: "apple", rank: 1000 });
|
|
|
|
const ctx = makeCtx(1, "private", "/semantle");
|
|
await handleSemantle(ctx, { db, client });
|
|
|
|
expect(client.randomWord).toHaveBeenCalledOnce();
|
|
expect(ctx.reply).toHaveBeenCalledOnce();
|
|
expect(ctx.replies[0].text).toContain("Round ready");
|
|
});
|
|
|
|
it("shows board with 0 guesses after fresh start", async () => {
|
|
client.randomWord.mockResolvedValue({ word: "target", rank: 500 });
|
|
|
|
const ctx = makeCtx(1, "private", "/semantle");
|
|
await handleSemantle(ctx, { db, client });
|
|
|
|
expect(ctx.replies[0].text).toContain("0 guesses");
|
|
expect(ctx.replies[0].opts.parse_mode).toBe("HTML");
|
|
});
|
|
|
|
it("reuses existing unsolved game", async () => {
|
|
client.randomWord.mockResolvedValue({ word: "apple", rank: 1000 });
|
|
|
|
const ctx1 = makeCtx(1, "private", "/semantle");
|
|
await handleSemantle(ctx1, { db, client });
|
|
expect(client.randomWord).toHaveBeenCalledTimes(1);
|
|
|
|
const ctx2 = makeCtx(1, "private", "/semantle");
|
|
await handleSemantle(ctx2, { db, client });
|
|
expect(client.randomWord).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("starts fresh game after solving", async () => {
|
|
// First game
|
|
client.randomWord.mockResolvedValueOnce({ word: "apple", rank: 1000 });
|
|
client.similarity.mockResolvedValueOnce({
|
|
a: "apple",
|
|
b: "apple",
|
|
in_vocab_a: true,
|
|
in_vocab_b: true,
|
|
canonical_b: "apple",
|
|
similarity: 1.0,
|
|
});
|
|
|
|
let ctx = makeCtx(1, "private", "/semantle apple");
|
|
await handleSemantle(ctx, { db, client });
|
|
expect(ctx.replies[0].text).toContain("Solved in 1 guess");
|
|
|
|
// Second game
|
|
client.randomWord.mockResolvedValueOnce({ word: "orange", rank: 1000 });
|
|
ctx = makeCtx(1, "private", "/semantle");
|
|
await handleSemantle(ctx, { db, client });
|
|
|
|
expect(client.randomWord).toHaveBeenCalledTimes(2);
|
|
expect(ctx.replies[0].text).toContain("0 guesses");
|
|
});
|
|
|
|
it("submits guess and appends to board", async () => {
|
|
client.randomWord.mockResolvedValue({ word: "apple", rank: 1000 });
|
|
client.similarity.mockResolvedValue({
|
|
a: "apple",
|
|
b: "orange",
|
|
in_vocab_a: true,
|
|
in_vocab_b: true,
|
|
canonical_b: "orange",
|
|
similarity: 0.45,
|
|
});
|
|
|
|
const ctx = makeCtx(1, "private", "/semantle orange");
|
|
await handleSemantle(ctx, { db, client });
|
|
|
|
expect(ctx.reply).toHaveBeenCalledOnce();
|
|
expect(ctx.replies[0].text).toContain("orange");
|
|
// raw 0.45 is below FLOOR of 0.40 (just barely above) → calibrate ≈ 08
|
|
expect(ctx.replies[0].text).toContain("08");
|
|
});
|
|
|
|
it("solves when guess equals target (case-insensitive)", async () => {
|
|
client.randomWord.mockResolvedValue({ word: "apple", rank: 1000 });
|
|
client.similarity.mockResolvedValue({
|
|
a: "apple",
|
|
b: "APPLE",
|
|
in_vocab_a: true,
|
|
in_vocab_b: true,
|
|
canonical_b: "apple",
|
|
similarity: 1.0,
|
|
});
|
|
|
|
const ctx = makeCtx(1, "private", "/semantle APPLE");
|
|
await handleSemantle(ctx, { db, client });
|
|
|
|
expect(ctx.replies[0].text).toContain("✅ Solved in 1 guess");
|
|
});
|
|
|
|
it("clears game after solve and records result", async () => {
|
|
client.randomWord.mockResolvedValue({ word: "apple", rank: 1000 });
|
|
client.similarity.mockResolvedValue({
|
|
a: "apple",
|
|
b: "apple",
|
|
in_vocab_a: true,
|
|
in_vocab_b: true,
|
|
canonical_b: "apple",
|
|
similarity: 1.0,
|
|
});
|
|
|
|
const ctx = makeCtx(1, "private", "/semantle apple");
|
|
await handleSemantle(ctx, { db, client });
|
|
|
|
// Verify game is cleared
|
|
const { loadGame, loadStats } = await import("../../../src/modules/semantle/state.js");
|
|
const game = await loadGame(db, 1);
|
|
expect(game).toBeNull();
|
|
|
|
// Verify stats recorded
|
|
const stats = await loadStats(db, 1);
|
|
expect(stats.played).toBe(1);
|
|
expect(stats.solved).toBe(1);
|
|
});
|
|
|
|
it("rejects invalid shape guess", async () => {
|
|
client.randomWord.mockResolvedValue({ word: "apple", rank: 1000 });
|
|
|
|
const ctx = makeCtx(1, "private", "/semantle 123abc");
|
|
await handleSemantle(ctx, { db, client });
|
|
|
|
expect(ctx.replies[0].text).toContain("letter-only");
|
|
expect(client.similarity).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects OOV guess and does not save to board", async () => {
|
|
client.randomWord.mockResolvedValue({ word: "apple", rank: 1000 });
|
|
client.similarity.mockResolvedValue({
|
|
a: "apple",
|
|
b: "xyz",
|
|
in_vocab_a: true,
|
|
in_vocab_b: false,
|
|
similarity: null,
|
|
});
|
|
|
|
const ctx = makeCtx(1, "private", "/semantle xyz");
|
|
await handleSemantle(ctx, { db, client });
|
|
|
|
expect(ctx.replies[0].text).toContain("vocabulary");
|
|
expect(ctx.replies[0].text).toContain("xyz");
|
|
|
|
// Verify guess was not saved
|
|
const { loadGame } = await import("../../../src/modules/semantle/state.js");
|
|
const game = await loadGame(db, 1);
|
|
expect(game.guesses.length).toBe(0);
|
|
});
|
|
|
|
it("replies already-guessed and skips API on re-submit", async () => {
|
|
client.randomWord.mockResolvedValue({ word: "apple", rank: 1000 });
|
|
client.similarity.mockResolvedValue({
|
|
a: "apple",
|
|
b: "orange",
|
|
in_vocab_a: true,
|
|
in_vocab_b: true,
|
|
canonical_b: "orange",
|
|
similarity: 0.45,
|
|
});
|
|
|
|
const ctx1 = makeCtx(1, "private", "/semantle orange");
|
|
await handleSemantle(ctx1, { db, client });
|
|
expect(client.similarity).toHaveBeenCalledTimes(1);
|
|
|
|
const ctx2 = makeCtx(1, "private", "/semantle orange");
|
|
await handleSemantle(ctx2, { db, client });
|
|
|
|
// Fast-path dedup: no second API call.
|
|
expect(client.similarity).toHaveBeenCalledTimes(1);
|
|
expect(ctx2.replies[0].text).toContain("already guessed");
|
|
expect(ctx2.replies[0].text).toContain("🔁");
|
|
|
|
const { loadGame } = await import("../../../src/modules/semantle/state.js");
|
|
const game = await loadGame(db, 1);
|
|
expect(game.guesses.length).toBe(1);
|
|
});
|
|
|
|
it("post-API dedup when canonical collides with a prior canonical", async () => {
|
|
client.randomWord.mockResolvedValue({ word: "apple", rank: 1000 });
|
|
// First guess "running" → canonical "run" recorded.
|
|
client.similarity.mockResolvedValueOnce({
|
|
a: "apple",
|
|
b: "running",
|
|
in_vocab_a: true,
|
|
in_vocab_b: true,
|
|
canonical_b: "run",
|
|
similarity: 0.3,
|
|
});
|
|
// Second guess "runs" (different word) → also canonical "run" — should reject.
|
|
client.similarity.mockResolvedValueOnce({
|
|
a: "apple",
|
|
b: "runs",
|
|
in_vocab_a: true,
|
|
in_vocab_b: true,
|
|
canonical_b: "run",
|
|
similarity: 0.3,
|
|
});
|
|
|
|
const ctx1 = makeCtx(1, "private", "/semantle running");
|
|
await handleSemantle(ctx1, { db, client });
|
|
const ctx2 = makeCtx(1, "private", "/semantle runs");
|
|
await handleSemantle(ctx2, { db, client });
|
|
|
|
expect(ctx2.replies[0].text).toContain("already guessed");
|
|
const { loadGame } = await import("../../../src/modules/semantle/state.js");
|
|
const game = await loadGame(db, 1);
|
|
expect(game.guesses.length).toBe(1);
|
|
});
|
|
|
|
it("sets startedAt on first guess", async () => {
|
|
client.randomWord.mockResolvedValue({ word: "apple", rank: 1000 });
|
|
client.similarity.mockResolvedValue({
|
|
a: "apple",
|
|
b: "orange",
|
|
in_vocab_a: true,
|
|
in_vocab_b: true,
|
|
canonical_b: "orange",
|
|
similarity: 0.45,
|
|
});
|
|
|
|
const before = Date.now();
|
|
const ctx = makeCtx(1, "private", "/semantle orange");
|
|
await handleSemantle(ctx, { db, client });
|
|
const after = Date.now();
|
|
|
|
const { loadGame } = await import("../../../src/modules/semantle/state.js");
|
|
const game = await loadGame(db, 1);
|
|
expect(game.startedAt).toBeGreaterThanOrEqual(before);
|
|
expect(game.startedAt).toBeLessThanOrEqual(after);
|
|
});
|
|
|
|
it("includes latest guess marker in render", async () => {
|
|
client.randomWord.mockResolvedValue({ word: "apple", rank: 1000 });
|
|
client.similarity
|
|
.mockResolvedValueOnce({
|
|
a: "apple",
|
|
b: "orange",
|
|
in_vocab_a: true,
|
|
in_vocab_b: true,
|
|
canonical_b: "orange",
|
|
similarity: 0.45,
|
|
})
|
|
.mockResolvedValueOnce({
|
|
a: "apple",
|
|
b: "banana",
|
|
in_vocab_a: true,
|
|
in_vocab_b: true,
|
|
canonical_b: "banana",
|
|
similarity: 0.35,
|
|
});
|
|
|
|
const ctx1 = makeCtx(1, "private", "/semantle orange");
|
|
await handleSemantle(ctx1, { db, client });
|
|
|
|
const ctx2 = makeCtx(1, "private", "/semantle banana");
|
|
await handleSemantle(ctx2, { db, client });
|
|
|
|
expect(ctx2.replies[0].text).toContain("➡️");
|
|
});
|
|
|
|
it("replies with UPSTREAM_FAIL on randomWord error", async () => {
|
|
client.randomWord.mockRejectedValue(new UpstreamError("timeout", { status: 504 }));
|
|
|
|
const ctx = makeCtx(1, "private", "/semantle");
|
|
await handleSemantle(ctx, { db, client });
|
|
|
|
expect(ctx.replies[0].text).toContain("⚠️ Upstream hiccup");
|
|
});
|
|
|
|
it("replies with UPSTREAM_FAIL on similarity error", async () => {
|
|
client.randomWord.mockResolvedValue({ word: "apple", rank: 1000 });
|
|
client.similarity.mockRejectedValue(new UpstreamError("network error"));
|
|
|
|
const ctx = makeCtx(1, "private", "/semantle guess");
|
|
await handleSemantle(ctx, { db, client });
|
|
|
|
expect(ctx.replies[0].text).toContain("⚠️ Upstream hiccup");
|
|
});
|
|
|
|
it("handles group chat (shared game)", async () => {
|
|
client.randomWord.mockResolvedValue({ word: "apple", rank: 1000 });
|
|
|
|
const ctx = makeCtx(-123456, "group", "/semantle");
|
|
await handleSemantle(ctx, { db, client });
|
|
|
|
expect(ctx.reply).toHaveBeenCalledOnce();
|
|
expect(ctx.replies[0].text).toContain("Round ready");
|
|
});
|
|
|
|
it("rejects when cannot identify subject", async () => {
|
|
const ctx = makeCtx();
|
|
ctx.chat = null;
|
|
ctx.from = null;
|
|
|
|
await handleSemantle(ctx, { db, client });
|
|
|
|
expect(ctx.replies[0].text).toContain("Cannot identify chat");
|
|
});
|
|
|
|
it("normalizes guess to lowercase", async () => {
|
|
client.randomWord.mockResolvedValue({ word: "apple", rank: 1000 });
|
|
client.similarity.mockResolvedValue({
|
|
a: "apple",
|
|
b: "ORANGE",
|
|
in_vocab_a: true,
|
|
in_vocab_b: true,
|
|
canonical_b: "orange",
|
|
similarity: 0.45,
|
|
});
|
|
|
|
const ctx = makeCtx(1, "private", "/semantle ORANGE ");
|
|
await handleSemantle(ctx, { db, client });
|
|
|
|
expect(client.similarity).toHaveBeenCalledWith("apple", "orange");
|
|
});
|
|
});
|
|
|
|
describe("handleGiveup", () => {
|
|
it("reveals target and clears game", async () => {
|
|
client.randomWord.mockResolvedValue({ word: "apple", rank: 1000 });
|
|
|
|
const ctx1 = makeCtx(1, "private", "/semantle");
|
|
await handleSemantle(ctx1, { db, client });
|
|
|
|
const ctx2 = makeCtx(1, "private", "/semantle_giveup");
|
|
await handleGiveup(ctx2, { db });
|
|
|
|
expect(ctx2.replies[0].text).toContain("<b>apple</b>");
|
|
expect(ctx2.replies[0].text).toContain("🏳️");
|
|
|
|
const { loadGame } = await import("../../../src/modules/semantle/state.js");
|
|
const game = await loadGame(db, 1);
|
|
expect(game).toBeNull();
|
|
});
|
|
|
|
it("records non-solve result when giveup", async () => {
|
|
client.randomWord.mockResolvedValue({ word: "apple", rank: 1000 });
|
|
client.similarity.mockResolvedValue({
|
|
a: "apple",
|
|
b: "orange",
|
|
in_vocab_a: true,
|
|
in_vocab_b: true,
|
|
canonical_b: "orange",
|
|
similarity: 0.45,
|
|
});
|
|
|
|
const ctx1 = makeCtx(1, "private", "/semantle orange");
|
|
await handleSemantle(ctx1, { db, client });
|
|
|
|
const ctx2 = makeCtx(1, "private", "/semantle_giveup");
|
|
await handleGiveup(ctx2, { db });
|
|
|
|
const { loadStats } = await import("../../../src/modules/semantle/state.js");
|
|
const stats = await loadStats(db, 1);
|
|
expect(stats.played).toBe(1);
|
|
expect(stats.solved).toBe(0);
|
|
expect(stats.totalGuesses).toBe(1);
|
|
});
|
|
|
|
it("replies 'no active round' when no game", async () => {
|
|
const ctx = makeCtx(1, "private", "/semantle_giveup");
|
|
await handleGiveup(ctx, { db });
|
|
|
|
expect(ctx.replies[0].text).toContain("No active round");
|
|
});
|
|
|
|
it("escapes HTML in target reveal", async () => {
|
|
client.randomWord.mockResolvedValue({ word: "<xss>", rank: 1000 });
|
|
|
|
const ctx1 = makeCtx(1, "private", "/semantle");
|
|
await handleSemantle(ctx1, { db, client });
|
|
|
|
const ctx2 = makeCtx(1, "private", "/semantle_giveup");
|
|
await handleGiveup(ctx2, { db });
|
|
|
|
expect(ctx2.replies[0].text).toContain("<xss>");
|
|
});
|
|
});
|
|
|
|
describe("handleStats", () => {
|
|
it("shows default message for new user", async () => {
|
|
const ctx = makeCtx(1, "private", "/semantle_stats");
|
|
await handleStats(ctx, { db });
|
|
|
|
expect(ctx.replies[0].text).toContain("No semantle games played yet");
|
|
});
|
|
|
|
it("shows stats after games", async () => {
|
|
client.randomWord.mockResolvedValue({ word: "apple", rank: 1000 });
|
|
client.similarity
|
|
.mockResolvedValueOnce({
|
|
a: "apple",
|
|
b: "apple",
|
|
in_vocab_a: true,
|
|
in_vocab_b: true,
|
|
canonical_b: "apple",
|
|
similarity: 1.0,
|
|
})
|
|
.mockResolvedValueOnce({
|
|
a: "banana",
|
|
b: "orange",
|
|
in_vocab_a: true,
|
|
in_vocab_b: true,
|
|
canonical_b: "orange",
|
|
similarity: 0.45,
|
|
})
|
|
.mockResolvedValueOnce({
|
|
a: "banana",
|
|
b: "grape",
|
|
in_vocab_a: true,
|
|
in_vocab_b: true,
|
|
canonical_b: "grape",
|
|
similarity: 0.35,
|
|
});
|
|
|
|
// Game 1: solve in 1 guess
|
|
const ctx1 = makeCtx(1, "private", "/semantle apple");
|
|
await handleSemantle(ctx1, { db, client });
|
|
|
|
// Game 2: lose after 2 guesses
|
|
client.randomWord.mockResolvedValueOnce({ word: "banana", rank: 1000 });
|
|
const ctx2a = makeCtx(1, "private", "/semantle");
|
|
await handleSemantle(ctx2a, { db, client });
|
|
|
|
const ctx2b = makeCtx(1, "private", "/semantle orange");
|
|
await handleSemantle(ctx2b, { db, client });
|
|
|
|
const ctx2c = makeCtx(1, "private", "/semantle grape");
|
|
await handleSemantle(ctx2c, { db, client });
|
|
|
|
const ctx2d = makeCtx(1, "private", "/semantle_giveup");
|
|
await handleGiveup(ctx2d, { db });
|
|
|
|
// Check stats
|
|
const ctx3 = makeCtx(1, "private", "/semantle_stats");
|
|
await handleStats(ctx3, { db });
|
|
|
|
const statsText = ctx3.replies[0].text;
|
|
expect(statsText).toContain("Played: 2");
|
|
expect(statsText).toContain("Solved: 1 (50%)");
|
|
expect(statsText).toContain("Total guesses: 3");
|
|
expect(statsText).toContain("Fewest to solve: 1");
|
|
expect(statsText).toContain("Avg per round: 2");
|
|
});
|
|
|
|
it("shows '—' for bestGuessCount when no solves", async () => {
|
|
client.randomWord.mockResolvedValue({ word: "apple", rank: 1000 });
|
|
client.similarity.mockResolvedValue({
|
|
a: "apple",
|
|
b: "orange",
|
|
in_vocab_a: true,
|
|
in_vocab_b: true,
|
|
canonical_b: "orange",
|
|
similarity: 0.45,
|
|
});
|
|
|
|
const ctx1 = makeCtx(1, "private", "/semantle orange");
|
|
await handleSemantle(ctx1, { db, client });
|
|
|
|
const ctx2 = makeCtx(1, "private", "/semantle_giveup");
|
|
await handleGiveup(ctx2, { db });
|
|
|
|
const ctx3 = makeCtx(1, "private", "/semantle_stats");
|
|
await handleStats(ctx3, { db });
|
|
|
|
expect(ctx3.replies[0].text).toContain("Fewest to solve: —");
|
|
});
|
|
|
|
it("calculates solve percentage correctly", async () => {
|
|
const { recordResult } = await import("../../../src/modules/semantle/state.js");
|
|
await recordResult(db, 1, { solved: true, guessCount: 2 });
|
|
await recordResult(db, 1, { solved: true, guessCount: 3 });
|
|
await recordResult(db, 1, { solved: false, guessCount: 4 });
|
|
await recordResult(db, 1, { solved: false, guessCount: 5 });
|
|
|
|
const ctx = makeCtx(1, "private", "/semantle_stats");
|
|
await handleStats(ctx, { db });
|
|
|
|
expect(ctx.replies[0].text).toContain("Solved: 2 (50%)");
|
|
});
|
|
|
|
it("formats average guesses per round", async () => {
|
|
const { recordResult } = await import("../../../src/modules/semantle/state.js");
|
|
await recordResult(db, 1, { solved: true, guessCount: 3 });
|
|
await recordResult(db, 1, { solved: false, guessCount: 5 });
|
|
|
|
const ctx = makeCtx(1, "private", "/semantle_stats");
|
|
await handleStats(ctx, { db });
|
|
|
|
expect(ctx.replies[0].text).toContain("Avg per round: 4");
|
|
});
|
|
|
|
it("includes HTML formatting", async () => {
|
|
const { recordResult } = await import("../../../src/modules/semantle/state.js");
|
|
await recordResult(db, 1, { solved: true, guessCount: 1 });
|
|
|
|
const ctx = makeCtx(1, "private", "/semantle_stats");
|
|
await handleStats(ctx, { db });
|
|
|
|
expect(ctx.replies[0].opts.parse_mode).toBe("HTML");
|
|
expect(ctx.replies[0].text).toContain("<b>");
|
|
});
|
|
});
|
|
});
|