mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-04-27 20:20:32 +00:00
feat(semantle): add word2vec guessing game module
Telegram commands /semantle, /semantle_new, /semantle_giveup, /semantle_stats. Round starts with /random pick from hosted word2sim; each guess scored via /similarity. Unlimited guesses; solve on case-insensitive exact match. New env var WORD2SIM_API_URL (wrangler.toml, .env.deploy). Includes module README and 90 unit tests covering api-client, state, format, render, and handlers.
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { Word2SimError, createClient } from "../../../src/modules/semantle/api-client.js";
|
||||
|
||||
describe("semantle/api-client", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("Word2SimError", () => {
|
||||
it("stores status and body metadata", () => {
|
||||
const err = new Word2SimError("test", { status: 404, body: "not found" });
|
||||
expect(err.message).toBe("test");
|
||||
expect(err.status).toBe(404);
|
||||
expect(err.body).toBe("not found");
|
||||
expect(err.name).toBe("Word2SimError");
|
||||
});
|
||||
|
||||
it("stores cause when provided", () => {
|
||||
const cause = new Error("underlying");
|
||||
const err = new Word2SimError("wrapper", { cause });
|
||||
expect(err.cause).toBe(cause);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createClient", () => {
|
||||
it("randomWord builds correct URL with filters", async () => {
|
||||
const client = createClient("https://api.test", { timeoutMs: 100 });
|
||||
global.fetch = vi.fn((url) => {
|
||||
expect(url).toContain("/random");
|
||||
expect(url).toContain("min_rank=5");
|
||||
expect(url).toContain("alpha=true");
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
text: () => Promise.resolve('{"word":"apple","rank":1234}'),
|
||||
});
|
||||
});
|
||||
const res = await client.randomWord({ min_rank: 5, alpha: true });
|
||||
expect(res.word).toBe("apple");
|
||||
expect(res.rank).toBe(1234);
|
||||
});
|
||||
|
||||
it("similarity builds URL with both words", async () => {
|
||||
const client = createClient("https://api.test", { timeoutMs: 100 });
|
||||
global.fetch = vi.fn((url) => {
|
||||
expect(url).toContain("/similarity");
|
||||
expect(url).toContain("a=apple");
|
||||
expect(url).toContain("b=orange");
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
text: () =>
|
||||
Promise.resolve(
|
||||
'{"a":"apple","b":"orange","in_vocab_a":true,"in_vocab_b":true,"similarity":0.45}',
|
||||
),
|
||||
});
|
||||
});
|
||||
const res = await client.similarity("apple", "orange");
|
||||
expect(res.similarity).toBe(0.45);
|
||||
});
|
||||
|
||||
it("URL-encodes special characters in params", async () => {
|
||||
const client = createClient("https://api.test", { timeoutMs: 100 });
|
||||
global.fetch = vi.fn((url) => {
|
||||
expect(url).toMatch(/search=hello/);
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
text: () => Promise.resolve('{"word":"test"}'),
|
||||
});
|
||||
});
|
||||
await client.randomWord({ search: "hello world" });
|
||||
expect(global.fetch).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("throws Word2SimError on non-2xx response", async () => {
|
||||
const client = createClient("https://api.test", { timeoutMs: 100 });
|
||||
global.fetch = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: () => Promise.resolve("Internal Server Error"),
|
||||
}),
|
||||
);
|
||||
await expect(client.randomWord()).rejects.toMatchObject({
|
||||
name: "Word2SimError",
|
||||
status: 500,
|
||||
body: "Internal Server Error",
|
||||
});
|
||||
});
|
||||
|
||||
it("throws Word2SimError when response is not valid JSON", async () => {
|
||||
const client = createClient("https://api.test", { timeoutMs: 100 });
|
||||
global.fetch = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
text: () => Promise.resolve("not json at all"),
|
||||
}),
|
||||
);
|
||||
await expect(client.randomWord()).rejects.toMatchObject({
|
||||
name: "Word2SimError",
|
||||
});
|
||||
});
|
||||
|
||||
it("throws Word2SimError on fetch failure", async () => {
|
||||
const client = createClient("https://api.test", { timeoutMs: 100 });
|
||||
global.fetch = vi.fn(() => Promise.reject(new Error("network error")));
|
||||
await expect(client.randomWord()).rejects.toThrow("word2sim fetch failed");
|
||||
});
|
||||
|
||||
it("uses custom timeout and truncates response body to 500 chars", async () => {
|
||||
const client = createClient("https://api.test", { timeoutMs: 50 });
|
||||
const longBody = "x".repeat(600);
|
||||
global.fetch = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: false,
|
||||
status: 400,
|
||||
text: () => Promise.resolve(longBody),
|
||||
}),
|
||||
);
|
||||
try {
|
||||
await client.randomWord();
|
||||
} catch (err) {
|
||||
expect(err.body.length).toBe(500);
|
||||
}
|
||||
});
|
||||
|
||||
it("includes User-Agent header", async () => {
|
||||
const client = createClient("https://api.test", { timeoutMs: 100 });
|
||||
global.fetch = vi.fn((_, opts) => {
|
||||
expect(opts.headers["User-Agent"]).toContain("miti99bot");
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
text: () => Promise.resolve('{"word":"test"}'),
|
||||
});
|
||||
});
|
||||
await client.randomWord();
|
||||
});
|
||||
|
||||
it("includes Accept header", async () => {
|
||||
const client = createClient("https://api.test", { timeoutMs: 100 });
|
||||
global.fetch = vi.fn((_, opts) => {
|
||||
expect(opts.headers.Accept).toBe("application/json");
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
text: () => Promise.resolve('{"word":"test"}'),
|
||||
});
|
||||
});
|
||||
await client.randomWord();
|
||||
});
|
||||
|
||||
it("handles trailing slashes in API base URL", async () => {
|
||||
const client = createClient("https://api.test///", { timeoutMs: 100 });
|
||||
global.fetch = vi.fn((url) => {
|
||||
expect(url.startsWith("https://api.test/")).toBe(true);
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
text: () => Promise.resolve('{"word":"test"}'),
|
||||
});
|
||||
});
|
||||
await client.randomWord();
|
||||
});
|
||||
|
||||
it("filters out undefined/null params", async () => {
|
||||
const client = createClient("https://api.test", { timeoutMs: 100 });
|
||||
global.fetch = vi.fn((url) => {
|
||||
expect(url).not.toContain("min_rank=");
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
text: () => Promise.resolve('{"word":"test"}'),
|
||||
});
|
||||
});
|
||||
await client.randomWord({ min_rank: undefined, max_rank: null });
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user