mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 18:17:02 +01:00
A custom alias is data the command table is built from, so it cannot be a live switch like the ones already on the tab. Instead the management page writes aliases_custom.json in the bot's state directory and then asks the bot to reload, in one step: whenever the answer is anything but `reloaded` -- a word another command has, a name no command has, a bot still connecting -- the file is restored byte for byte and the bot's own reason is shown next to the field. What is on disk is therefore always what the bot serves, and a typo cannot sit in the file poisoning every later reload. The registry appends the operator's words to the shipped list at registration, which keeps `!pomoc` on the shipped spelling and routes the new words through the same clash check as aliases.json. An alias for a name no command has fails the whole build (ConfigError -> `kept`), because a silently ignored entry is how a file drifts. The tagger treats custom aliases as reserved words too, so a person cannot be given one. registry.py crossed the 250-line cap and now re-exports the record and the error types from command.py; the bot's state directory lookup, previously duplicated in pack.py and the suggestions export, is state_dir.py. Proven end to end: !e2ekot is unknown, the file is written and reload.sh runs, !e2ekot posts a cat, a file naming a command that does not exist is refused with 409 and the previous table still answers. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
76 lines
3.3 KiB
TypeScript
76 lines
3.3 KiB
TypeScript
import { render, screen, waitFor } from "@testing-library/react";
|
|
import userEvent from "@testing-library/user-event";
|
|
import { afterEach, expect, it, vi } from "vitest";
|
|
|
|
import { serve } from "../test/http.ts";
|
|
import { AliasField, wordsOf } from "./commands-aliases.tsx";
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
const RELOADED = { commands: 28, name: "cat", status: "reloaded", triggers: ["!kotek", "!kicia"] };
|
|
|
|
function field(initial: string[] = []): void {
|
|
render(<AliasField initial={initial} name="cat" trigger="!kot" />);
|
|
}
|
|
|
|
it("splits on commas and whitespace alike, and keeps no empty word", () => {
|
|
expect(wordsOf("!kotek, !kicia")).toEqual(["!kotek", "!kicia"]);
|
|
expect(wordsOf(" kotek kicia ,, ")).toEqual(["kotek", "kicia"]);
|
|
expect(wordsOf("")).toEqual([]);
|
|
});
|
|
|
|
it("shows the words the command already has, on one line", () => {
|
|
field(["!kotek", "!kicia"]);
|
|
expect(screen.getByLabelText("!kot aliases")).toHaveValue("!kotek, !kicia");
|
|
});
|
|
|
|
it("sends the words typed and shows the line the bot took, with its count", async () => {
|
|
const fetcher = serve({ "/api/aliases/cat": { body: RELOADED } });
|
|
field();
|
|
await userEvent.type(screen.getByLabelText("!kot aliases"), "kotek kicia");
|
|
await userEvent.click(screen.getByRole("button", { name: "Aliases !kot" }));
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("aliases-note-cat")).toHaveTextContent("reloaded, 28 commands");
|
|
});
|
|
const [url, init] = fetcher.mock.calls[0] as [string, RequestInit];
|
|
expect(url).toBe("/api/aliases/cat");
|
|
expect(init.method).toBe("PUT");
|
|
expect(JSON.parse(init.body as string)).toEqual({ triggers: ["kotek", "kicia"] });
|
|
// The bot's spelling wins: the "!" it added is what the field shows now.
|
|
expect(screen.getByLabelText("!kot aliases")).toHaveValue("!kotek, !kicia");
|
|
});
|
|
|
|
it("escapes a command name with a colon in it, as the pack's have", async () => {
|
|
const fetcher = serve({ "/api/aliases/pack%3Ap1": { body: { ...RELOADED, name: "pack:p1" } } });
|
|
render(<AliasField initial={[]} name="pack:p1" trigger="!alfa" />);
|
|
await userEvent.click(screen.getByRole("button", { name: "Aliases !alfa" }));
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("aliases-note-pack:p1")).toHaveTextContent("reloaded");
|
|
});
|
|
const [url] = fetcher.mock.calls[0] as [string];
|
|
expect(url).toBe("/api/aliases/pack%3Ap1");
|
|
});
|
|
|
|
it("shows the bot's own sentence when the reload is refused", async () => {
|
|
serve({ "/api/aliases/cat": { body: { detail: "trigger '!pies' is taken" }, status: 409 } });
|
|
field();
|
|
await userEvent.type(screen.getByLabelText("!kot aliases"), "pies");
|
|
await userEvent.click(screen.getByRole("button", { name: "Aliases !kot" }));
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("aliases-note-cat")).toHaveTextContent("trigger '!pies' is taken");
|
|
});
|
|
// Refused means not kept -- the field still says what was typed, so it can be fixed.
|
|
expect(screen.getByLabelText("!kot aliases")).toHaveValue("pies");
|
|
});
|
|
|
|
it("says 'could not save' when the failure is not the server's", async () => {
|
|
vi.stubGlobal("fetch", vi.fn(() => Promise.reject(new TypeError("offline"))));
|
|
field();
|
|
await userEvent.click(screen.getByRole("button", { name: "Aliases !kot" }));
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("aliases-note-cat")).toHaveTextContent("could not save");
|
|
});
|
|
});
|