feat(companion): rename Tabby companion to Sagi, replace cat avatar with mascot
Full rename across files, identifiers, CSS classes, and user-facing strings (en/vi locales, ARCHITECTURE.md). Replaces the hand-drawn animated cat avatar with the pig-superhero mascot artwork, split into parts to keep per-eye cursor tracking and blink animation working; whole-mascot mood transforms (breathe/bob/shake/tilt) and new zzz/bang/sparkle overlays carry the rest of the mood expression since the traced art has no shared palette to key off of.
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* @file Sagi.test.tsx
|
||||
* @description Render tests for the Sagi companion component — mounting, mood rendering, the ⌘B panel toggle, and accessibility attributes.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { render, screen, fireEvent, act, cleanup, within } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { Sagi } from "../Sagi";
|
||||
import { eventBus } from "../../../lib/eventBus";
|
||||
import type { WSMessage, Session } from "../../../lib/types";
|
||||
|
||||
function renderSagi() {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<Sagi />
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
||||
const sessionMsg = (id: string, status: Session["status"]): WSMessage => ({
|
||||
type: "session_updated",
|
||||
data: { id, status } as Session,
|
||||
timestamp: "t",
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
eventBus.setConnected(true);
|
||||
// Freeze timers so the brain's 1s heartbeat tick can't fire a state update
|
||||
// outside act() mid-assertion. We never advance them in these tests.
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("Sagi widget", () => {
|
||||
it("renders the avatar button by default", () => {
|
||||
renderSagi();
|
||||
expect(screen.getByRole("button", { name: /open sagi companion/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("img", { name: /sagi/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the panel on click and answers a local status question", () => {
|
||||
renderSagi();
|
||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||
const panel = screen.getByRole("dialog", { name: /sagi companion/i });
|
||||
expect(panel).toBeInTheDocument();
|
||||
|
||||
const input = within(panel).getByLabelText(/ask sagi/i);
|
||||
fireEvent.change(input, { target: { value: "status" } });
|
||||
fireEvent.submit(input.closest("form")!);
|
||||
expect(within(panel).getByText(/live ·/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("toggles open/closed with Cmd/Ctrl+B and closes with Esc", () => {
|
||||
renderSagi();
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
act(() => {
|
||||
fireEvent.keyDown(window, { key: "b", metaKey: true });
|
||||
});
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
act(() => {
|
||||
fireEvent.keyDown(window, { key: "Escape" });
|
||||
});
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows an error badge when a session errors", () => {
|
||||
renderSagi();
|
||||
act(() => {
|
||||
eventBus.publish(sessionMsg("a", "error"));
|
||||
});
|
||||
const btn = screen.getByRole("button", { name: /open sagi companion/i });
|
||||
expect(within(btn).getByText("1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reflects the live count in the panel status", () => {
|
||||
renderSagi();
|
||||
act(() => {
|
||||
eventBus.publish(sessionMsg("a", "active"));
|
||||
eventBus.publish(sessionMsg("b", "active"));
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||
const panel = screen.getByRole("dialog", { name: /sagi companion/i });
|
||||
// The "live" stat chip shows value 2 next to its label.
|
||||
const liveChip = within(panel).getByText("live").closest("div")!;
|
||||
expect(within(liveChip).getByText("2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("respects the enabled preference", () => {
|
||||
localStorage.setItem("agent-dashboard-sagi-enabled", "false");
|
||||
renderSagi();
|
||||
expect(screen.queryByRole("button", { name: /open sagi companion/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("a tap (no movement) still opens the panel", () => {
|
||||
renderSagi();
|
||||
const btn = screen.getByRole("button", { name: /open sagi companion/i });
|
||||
act(() => {
|
||||
fireEvent.pointerDown(btn, { clientX: 990, clientY: 700, button: 0 });
|
||||
fireEvent.pointerUp(btn, { clientX: 990, clientY: 700 });
|
||||
});
|
||||
fireEvent.click(btn);
|
||||
expect(screen.getByRole("dialog", { name: /sagi companion/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("dragging snaps to an edge, persists position, and does not open the panel", () => {
|
||||
renderSagi();
|
||||
const btn = screen.getByRole("button", { name: /open sagi companion/i });
|
||||
// Default dock is bottom-right. Drag far to the left past the threshold.
|
||||
act(() => {
|
||||
fireEvent.pointerDown(btn, { clientX: 990, clientY: 700, button: 0 });
|
||||
fireEvent.pointerMove(btn, { clientX: 80, clientY: 300 });
|
||||
fireEvent.pointerUp(btn, { clientX: 80, clientY: 300 });
|
||||
});
|
||||
// The synthetic click that follows a drag must be swallowed.
|
||||
fireEvent.click(btn);
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
const saved = JSON.parse(localStorage.getItem("agent-dashboard-sagi-pos") || "{}");
|
||||
expect(saved.side).toBe("left");
|
||||
expect(typeof saved.y).toBe("number");
|
||||
});
|
||||
|
||||
it("a sub-threshold pointer move is treated as a tap, not a drag", () => {
|
||||
renderSagi();
|
||||
const btn = screen.getByRole("button", { name: /open sagi companion/i });
|
||||
act(() => {
|
||||
fireEvent.pointerDown(btn, { clientX: 990, clientY: 700, button: 0 });
|
||||
fireEvent.pointerMove(btn, { clientX: 992, clientY: 701 }); // < 5px threshold
|
||||
fireEvent.pointerUp(btn, { clientX: 992, clientY: 701 });
|
||||
});
|
||||
fireEvent.click(btn);
|
||||
expect(screen.getByRole("dialog", { name: /sagi companion/i })).toBeInTheDocument();
|
||||
// No position was persisted because no real drag happened.
|
||||
expect(localStorage.getItem("agent-dashboard-sagi-pos")).toBeNull();
|
||||
});
|
||||
|
||||
it("restores a persisted left-edge position on mount", () => {
|
||||
localStorage.setItem("agent-dashboard-sagi-pos", JSON.stringify({ side: "left", y: 0.2 }));
|
||||
renderSagi();
|
||||
const btn = screen.getByRole("button", { name: /open sagi companion/i }) as HTMLElement;
|
||||
// Left-docked → inline left equals the edge margin (16px).
|
||||
expect(btn.style.left).toBe("16px");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* @file brain.test.ts
|
||||
* @description Unit tests for the Sagi state brain — initial state, mood transitions driven by dashboard events, and status derivation.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
initialSagiState,
|
||||
reduceSagi,
|
||||
deriveMood,
|
||||
statusOf,
|
||||
clearErrors,
|
||||
seedSessions,
|
||||
HAPPY_MS,
|
||||
WORRIED_MS,
|
||||
STUCK_MS,
|
||||
SLEEP_MS,
|
||||
type SagiState,
|
||||
} from "../brain";
|
||||
import type {
|
||||
WSMessage,
|
||||
Session,
|
||||
Agent,
|
||||
DashboardEvent,
|
||||
RunStatusPayload,
|
||||
} from "../../../lib/types";
|
||||
|
||||
const T0 = 1_000_000;
|
||||
|
||||
function sessionMsg(id: string, status: Session["status"], ts = T0): WSMessage {
|
||||
return { type: "session_updated", data: { id, status } as Session, timestamp: String(ts) };
|
||||
}
|
||||
function agentMsg(status: Agent["status"]): WSMessage {
|
||||
return { type: "agent_updated", data: { status } as Agent, timestamp: String(T0) };
|
||||
}
|
||||
function agentCreatedMsg(type: Agent["type"], status: Agent["status"] = "working"): WSMessage {
|
||||
return { type: "agent_created", data: { type, status } as Agent, timestamp: String(T0) };
|
||||
}
|
||||
function waitingMsg(id: string): WSMessage {
|
||||
return {
|
||||
type: "session_updated",
|
||||
data: { id, status: "active", awaiting_input_since: "2026-05-29T00:00:00Z" } as Session,
|
||||
timestamp: String(T0),
|
||||
};
|
||||
}
|
||||
function eventMsg(event_type: string): WSMessage {
|
||||
return { type: "new_event", data: { event_type } as DashboardEvent, timestamp: String(T0) };
|
||||
}
|
||||
function runStatusMsg(d: Partial<RunStatusPayload>): WSMessage {
|
||||
return { type: "run_status", data: d as RunStatusPayload, timestamp: String(T0) };
|
||||
}
|
||||
|
||||
describe("deriveMood priority", () => {
|
||||
it("disconnected outranks everything", () => {
|
||||
const s: SagiState = { ...initialSagiState(T0), connected: false, worriedUntil: T0 + 9999 };
|
||||
expect(deriveMood(s, T0)).toBe("disconnected");
|
||||
});
|
||||
|
||||
it("worried outranks stuck", () => {
|
||||
let s = initialSagiState(T0);
|
||||
({ state: s } = reduceSagi(s, sessionMsg("a", "active"), T0)); // live
|
||||
s = { ...s, lastActivityAt: T0 - STUCK_MS - 1, worriedUntil: T0 + 100 };
|
||||
expect(deriveMood(s, T0)).toBe("worried");
|
||||
});
|
||||
|
||||
it("stuck when a live session goes silent past STUCK_MS", () => {
|
||||
let s = initialSagiState(T0);
|
||||
({ state: s } = reduceSagi(s, sessionMsg("a", "active"), T0));
|
||||
expect(deriveMood(s, T0 + STUCK_MS + 1)).toBe("stuck");
|
||||
});
|
||||
|
||||
it("happy is transient then falls back to idle when nothing live", () => {
|
||||
let s = initialSagiState(T0);
|
||||
({ state: s } = reduceSagi(s, sessionMsg("a", "active"), T0));
|
||||
({ state: s } = reduceSagi(s, sessionMsg("a", "completed"), T0)); // no longer live
|
||||
expect(deriveMood(s, T0 + 10)).toBe("happy");
|
||||
expect(deriveMood(s, T0 + HAPPY_MS + 1)).toBe("idle");
|
||||
});
|
||||
|
||||
it("thinking shows when set and nothing higher applies", () => {
|
||||
const s = { ...initialSagiState(T0), thinking: true };
|
||||
expect(deriveMood(s, T0)).toBe("thinking");
|
||||
});
|
||||
|
||||
it("watching when a session is live and recent", () => {
|
||||
let s = initialSagiState(T0);
|
||||
({ state: s } = reduceSagi(s, sessionMsg("a", "active"), T0));
|
||||
expect(deriveMood(s, T0 + 1000)).toBe("watching");
|
||||
});
|
||||
|
||||
it("sleeping after SLEEP_MS of no activity and nothing live", () => {
|
||||
const s = initialSagiState(T0);
|
||||
expect(deriveMood(s, T0 + SLEEP_MS + 1)).toBe("sleeping");
|
||||
});
|
||||
|
||||
it("idle by default", () => {
|
||||
expect(deriveMood(initialSagiState(T0), T0)).toBe("idle");
|
||||
});
|
||||
});
|
||||
|
||||
describe("reduceSagi counts and pulses", () => {
|
||||
it("tracks live count accurately across transitions", () => {
|
||||
let s = initialSagiState(T0);
|
||||
({ state: s } = reduceSagi(s, sessionMsg("a", "active"), T0));
|
||||
({ state: s } = reduceSagi(s, sessionMsg("b", "active"), T0));
|
||||
expect(statusOf(s).liveCount).toBe(2);
|
||||
({ state: s } = reduceSagi(s, sessionMsg("a", "completed"), T0));
|
||||
expect(statusOf(s).liveCount).toBe(1);
|
||||
});
|
||||
|
||||
it("counts errored sessions and emits error pulse", () => {
|
||||
let s = initialSagiState(T0);
|
||||
const r = reduceSagi(s, sessionMsg("a", "error"), T0);
|
||||
s = r.state;
|
||||
expect(r.pulse).toBe("error");
|
||||
expect(statusOf(s).errorCount).toBe(1);
|
||||
expect(deriveMood(s, T0)).toBe("worried");
|
||||
});
|
||||
|
||||
it("session_start pulse only on first active transition", () => {
|
||||
let s = initialSagiState(T0);
|
||||
const r1 = reduceSagi(s, sessionMsg("a", "active"), T0);
|
||||
expect(r1.pulse).toBe("session_start");
|
||||
const r2 = reduceSagi(r1.state, sessionMsg("a", "active"), T0);
|
||||
expect(r2.pulse).toBe(null);
|
||||
});
|
||||
|
||||
it("session_done pulse only when the session was tracked", () => {
|
||||
let s = initialSagiState(T0);
|
||||
const untracked = reduceSagi(s, sessionMsg("ghost", "completed"), T0);
|
||||
expect(untracked.pulse).toBe(null);
|
||||
({ state: s } = reduceSagi(s, sessionMsg("a", "active"), T0));
|
||||
const done = reduceSagi(s, sessionMsg("a", "completed"), T0);
|
||||
expect(done.pulse).toBe("session_done");
|
||||
});
|
||||
|
||||
it("agent error triggers worried via pulse", () => {
|
||||
const r = reduceSagi(initialSagiState(T0), agentMsg("error"), T0);
|
||||
expect(r.pulse).toBe("error");
|
||||
expect(deriveMood(r.state, T0)).toBe("worried");
|
||||
});
|
||||
|
||||
it("a newly created subagent emits subagent_spawn, a main agent does not", () => {
|
||||
expect(reduceSagi(initialSagiState(T0), agentCreatedMsg("subagent"), T0).pulse).toBe(
|
||||
"subagent_spawn"
|
||||
);
|
||||
expect(reduceSagi(initialSagiState(T0), agentCreatedMsg("main"), T0).pulse).toBe(null);
|
||||
});
|
||||
|
||||
it("waiting transition emits a waiting pulse once and still counts as live", () => {
|
||||
let s = initialSagiState(T0);
|
||||
({ state: s } = reduceSagi(s, sessionMsg("a", "active"), T0)); // active
|
||||
const first = reduceSagi(s, waitingMsg("a"), T0);
|
||||
expect(first.pulse).toBe("waiting");
|
||||
expect(statusOf(first.state).liveCount).toBe(1);
|
||||
// A repeat waiting update does not re-announce.
|
||||
const second = reduceSagi(first.state, waitingMsg("a"), T0);
|
||||
expect(second.pulse).toBe(null);
|
||||
});
|
||||
|
||||
it("failure event types set worried, normal events do not", () => {
|
||||
const fail = reduceSagi(initialSagiState(T0), eventMsg("toolError"), T0);
|
||||
expect(fail.pulse).toBe("error");
|
||||
const ok = reduceSagi(initialSagiState(T0), eventMsg("postToolUse"), T0);
|
||||
expect(ok.pulse).toBe(null);
|
||||
expect(ok.state.worriedUntil).toBe(0);
|
||||
});
|
||||
|
||||
it("run_status updates activity timestamp only (no exit code available)", () => {
|
||||
const running = reduceSagi(initialSagiState(T0), runStatusMsg({ status: "running" }), T0);
|
||||
expect(running.pulse).toBe(null);
|
||||
const gone = reduceSagi(initialSagiState(T0), runStatusMsg({ status: "gone" }), T0);
|
||||
expect(gone.pulse).toBe(null);
|
||||
});
|
||||
|
||||
it("any handled message refreshes lastActivityAt", () => {
|
||||
const s = { ...initialSagiState(T0), lastActivityAt: T0 - 99999 };
|
||||
const { state } = reduceSagi(s, eventMsg("postToolUse"), T0 + 5);
|
||||
expect(state.lastActivityAt).toBe(T0 + 5);
|
||||
});
|
||||
|
||||
it("ignores unrelated message types without mutating", () => {
|
||||
const s = initialSagiState(T0);
|
||||
const r = reduceSagi(s, { type: "import.progress", data: {} as never, timestamp: "x" }, T0);
|
||||
expect(r.state).toBe(s);
|
||||
expect(r.pulse).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("seedSessions", () => {
|
||||
it("hydrates live/waiting/errored counts from a REST snapshot", () => {
|
||||
const s = seedSessions(
|
||||
initialSagiState(T0),
|
||||
[
|
||||
{ id: "a", status: "active" },
|
||||
{ id: "b", status: "active", awaiting_input_since: "2026-05-29T00:00:00Z" },
|
||||
{ id: "c", status: "error" },
|
||||
{ id: "d", status: "completed" }, // ignored
|
||||
],
|
||||
T0
|
||||
);
|
||||
const st = statusOf(s);
|
||||
expect(st.liveCount).toBe(2); // a + b
|
||||
expect(st.waitingCount).toBe(1); // b
|
||||
expect(st.errorCount).toBe(1); // c
|
||||
});
|
||||
});
|
||||
|
||||
describe("statusOf waiting", () => {
|
||||
it("counts a waiting session as both live and waiting", () => {
|
||||
let s = initialSagiState(T0);
|
||||
({ state: s } = reduceSagi(s, waitingMsg("a"), T0));
|
||||
expect(statusOf(s)).toMatchObject({ liveCount: 1, waitingCount: 1, errorCount: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearErrors", () => {
|
||||
it("drops errored sessions but keeps active ones", () => {
|
||||
let s = initialSagiState(T0);
|
||||
({ state: s } = reduceSagi(s, sessionMsg("a", "active"), T0));
|
||||
({ state: s } = reduceSagi(s, sessionMsg("b", "error"), T0));
|
||||
s = clearErrors(s);
|
||||
expect(statusOf(s).errorCount).toBe(0);
|
||||
expect(statusOf(s).liveCount).toBe(1);
|
||||
expect(s.worriedUntil).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// Reference the imported constant so it is exercised and tsc-clean.
|
||||
it("WORRIED_MS is a positive window", () => {
|
||||
expect(WORRIED_MS).toBeGreaterThan(0);
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* @file intents.test.ts
|
||||
* @description Unit tests for Sagi's intent matcher — natural-language queries mapped to dashboard navigation and status intents.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { matchIntent } from "../intents";
|
||||
import type { SagiStatus } from "../brain";
|
||||
|
||||
const status = (over: Partial<SagiStatus> = {}): SagiStatus => ({
|
||||
liveCount: 0,
|
||||
waitingCount: 0,
|
||||
errorCount: 0,
|
||||
connected: true,
|
||||
...over,
|
||||
});
|
||||
|
||||
describe("matchIntent", () => {
|
||||
it("reports live sessions", () => {
|
||||
const r = matchIntent("what's running?", status({ liveCount: 3 }));
|
||||
expect(r).toEqual({ kind: "answer", text: expect.stringContaining("3 sessions live") });
|
||||
});
|
||||
|
||||
it("singularizes correctly", () => {
|
||||
const r = matchIntent("anything active", status({ liveCount: 1 }));
|
||||
expect(r.kind).toBe("answer");
|
||||
if (r.kind === "answer") expect(r.text).toContain("1 session live");
|
||||
});
|
||||
|
||||
it("says all quiet when nothing live", () => {
|
||||
const r = matchIntent("what is running", status());
|
||||
if (r.kind === "answer") expect(r.text).toContain("nothing's running");
|
||||
});
|
||||
|
||||
it("reports errors and prioritizes error intent over live", () => {
|
||||
const r = matchIntent("any failed runs?", status({ liveCount: 2, errorCount: 1 }));
|
||||
if (r.kind === "answer") expect(r.text).toContain("1 session errored");
|
||||
});
|
||||
|
||||
it("clean when no errors", () => {
|
||||
const r = matchIntent("are there errors", status({ liveCount: 2 }));
|
||||
if (r.kind === "answer") expect(r.text).toContain("all clean");
|
||||
});
|
||||
|
||||
it("gives a combined status summary", () => {
|
||||
const r = matchIntent(
|
||||
"status",
|
||||
status({ liveCount: 2, waitingCount: 1, errorCount: 1, connected: true })
|
||||
);
|
||||
if (r.kind === "answer") expect(r.text).toBe("2 live · 1 waiting · 1 errored · connected.");
|
||||
});
|
||||
|
||||
it("reports sessions waiting on the user", () => {
|
||||
const r = matchIntent("anything waiting on me?", status({ liveCount: 2, waitingCount: 1 }));
|
||||
if (r.kind === "answer") expect(r.text).toContain("1 session waiting on you");
|
||||
});
|
||||
|
||||
it("reflects offline in summary", () => {
|
||||
const r = matchIntent("overview", status({ connected: false }));
|
||||
if (r.kind === "answer") expect(r.text).toContain("offline");
|
||||
});
|
||||
|
||||
it("explains itself on help", () => {
|
||||
const r = matchIntent("help", status());
|
||||
if (r.kind === "answer") expect(r.text.toLowerCase()).toContain("watch your sessions");
|
||||
});
|
||||
|
||||
it("empty query nudges the user", () => {
|
||||
const r = matchIntent(" ", status());
|
||||
expect(r.kind).toBe("answer");
|
||||
});
|
||||
|
||||
it("hands unknown questions to Claude, preserving original casing", () => {
|
||||
const r = matchIntent("Refactor my auth module", status());
|
||||
expect(r).toEqual({ kind: "handoff", prompt: "Refactor my auth module" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* @file quips.test.ts
|
||||
* @description Unit tests for Sagi's quip picker — full key coverage and stable selection behavior for the speech-bubble lines.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { pickQuip, ALL_QUIP_KEYS } from "../quips";
|
||||
|
||||
describe("pickQuip", () => {
|
||||
it("returns a non-empty string for every known key", () => {
|
||||
for (const key of ALL_QUIP_KEYS) {
|
||||
expect(pickQuip(key, () => 0).length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("is deterministic given an injected rand", () => {
|
||||
expect(pickQuip("session_done", () => 0)).toBe(pickQuip("session_done", () => 0));
|
||||
});
|
||||
|
||||
it("rand=0.999 stays within bounds (no out-of-range index)", () => {
|
||||
for (const key of ALL_QUIP_KEYS) {
|
||||
expect(typeof pickQuip(key, () => 0.999)).toBe("string");
|
||||
expect(pickQuip(key, () => 0.999).length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns empty string for an unknown key without throwing", () => {
|
||||
// @ts-expect-error intentionally passing an invalid key
|
||||
expect(pickQuip("nope", () => 0)).toBe("");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user