Files
AgentHub/client/src/components/Sagi/__tests__/Pets.test.tsx
T
nntrivi2001 0cb5e41faf feat(pet): cleared alerts stay cleared, contextual bubbles, i18n, moments, chat window
- Fix: an errored session alerted on every update, and Clear alerts only
  dropped it in memory, so its next update (or a reload) brought the alert
  straight back. An error now alerts on the transition into error only;
  cleared sessions are remembered (agenthub.pet.dismissedErrors) and stay
  quiet until they recover and fail anew.
- Quips, greetings, notifications and Ask answers move to i18n (EN/VI);
  Ask understands Vietnamese keywords and names the sessions it counts.
- The pet goes by the worn pet's name; click it in the panel to rename
  (agenthub.pet.names, synced to the account).
- Bubbles name the session they are about, with an Open button (never a
  resume); a burst of the same event coalesces into one count.
- Moments: a new session waves, a finish hops, a subagent peeks, a wait taps,
  an error flinches — on Sagi, Codex sprites and still images. Idle fidgets,
  a once-per-session greeting, a "Needs you" list, an amber waiting dot.
- Pat (heart button, Ask, or stroking the pet) reacts on the pet itself, so
  it shows while the panel or chat is open.
- Panel drops Run Claude / Activity / Sessions / Errored.
- Ask hands off to a chat window (PetChat) on the default AI tool
  (Claude/Codex): transcript as bubbles, follow-ups and CLI menus through an
  off-screen attached terminal; no new server route.

Verified on HEAD + this diff in a clean worktree: tsc -b, the full client
suite (1334/1335; the one failure, ConversationView.follow, is flaky under
load and passes 3/3 alone) and server me-prefs.test.js.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:14:29 +07:00

359 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @file Pets.test.tsx
* @description The mascot's custom pets: `PetSprite` plays the spritesheet
* row of Sagi's mood and steps through its frames (a still image bobs instead),
* and `PetMenu` hides the mascot, sizes it, picks Sagi / a featured pet
* (downloading it on the first pick) / an own pet, deletes an own pet (falling
* back to Sagi when it was the one worn), and uploads an image; Settings →
* Pet (`PetSettings`) does the same from a grid, plus show / bubbles / size.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, act, waitFor } from "@testing-library/react";
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
const own = {
id: "p-aaaaaaaaaaaa",
displayName: "My cat",
description: "",
kind: "image",
source: "upload",
};
return {
...actual,
api: {
pets: {
spriteUrl: (id: string) => `/api/pets/${id}/sprite`,
list: vi.fn(async () => ({
pets: [own],
featured: [
{ id: "goku", displayName: "Goku", by: "hackminit", installed: false, page: "" },
],
sheet: { width: 1536, height: 1872, cellWidth: 192, cellHeight: 208 },
})),
installFeatured: vi.fn(async () => ({
pet: {
id: "cp-goku",
displayName: "Goku",
description: "",
kind: "codex",
source: "featured",
featuredId: "goku",
},
})),
upload: vi.fn(async () => ({
pet: {
id: "p-bbbbbbbbbbbb",
displayName: "Dog",
description: "",
kind: "image",
source: "upload",
},
})),
importBundle: vi.fn(),
remove: vi.fn(async () => ({ ok: true })),
},
},
};
});
import { api } from "../../../lib/api";
import { PetSprite } from "../PetSprite";
import { PetMenu } from "../PetMenu";
import { PetSettings } from "../../settings/PetSettings";
import { sagiPrefs } from "../prefs";
import { settleConfirm, subscribeConfirm, type PendingConfirm } from "../../../lib/confirm";
import { subscribeToasts, type ToastItem } from "../../../lib/toast";
/** Answer the pending confirmDialog (asserting one is open). */
function answerConfirm(ok: boolean) {
let pending: PendingConfirm | null = null;
subscribeConfirm((p) => (pending = p))();
expect(pending).not.toBeNull();
act(() => settleConfirm(ok));
}
describe("PetSprite", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it("plays the row of the mood and steps through its frames", () => {
const { rerender } = render(
<PetSprite
id="cp-goku"
kind="codex"
mood="thinking"
size={104}
reducedMotion={false}
onBroken={() => {}}
/>
);
const sprite = screen.getByTestId("pet-sprite");
// 104 px tall = half a 208 px cell: row 7 (running) starts 7 × 104 px down.
expect(sprite.dataset.row).toBe("7");
expect(sprite.style.backgroundPosition).toBe("0px -728px");
act(() => vi.advanceTimersByTime(110));
expect(sprite.style.backgroundPosition).toBe("0px -728px"); // Codex: 120 ms a frame
act(() => vi.advanceTimersByTime(10));
expect(sprite.style.backgroundPosition).toBe("-96px -728px");
rerender(
<PetSprite
id="cp-goku"
kind="codex"
mood="worried"
size={104}
reducedMotion={false}
onBroken={() => {}}
/>
);
expect(screen.getByTestId("pet-sprite").dataset.row).toBe("5");
});
it("runs the way it is dragged and waves on hover, over its mood", () => {
const props = {
id: "cp-goku",
kind: "codex" as const,
mood: "thinking" as const,
size: 208,
reducedMotion: false,
onBroken: () => {},
};
const { rerender } = render(<PetSprite {...props} action="run-left" />);
expect(screen.getByTestId("pet-sprite").dataset.row).toBe("2");
rerender(<PetSprite {...props} action="run-right" />);
expect(screen.getByTestId("pet-sprite").dataset.row).toBe("1");
rerender(<PetSprite {...props} action="wave" />);
expect(screen.getByTestId("pet-sprite").dataset.row).toBe("3");
rerender(<PetSprite {...props} action={null} />);
expect(screen.getByTestId("pet-sprite").dataset.row).toBe("7");
});
it("plays idle at the drawn pace, the last frame barely longer than the rest", () => {
render(
<PetSprite
id="cp-goku"
kind="codex"
mood="idle"
size={208}
reducedMotion={false}
onBroken={() => {}}
/>
);
const sprite = screen.getByTestId("pet-sprite");
act(() => vi.advanceTimersByTime(280 + 110 + 110 + 140 + 140)); // → the 6th, last frame
expect(sprite.style.backgroundPosition).toBe("-960px 0px");
act(() => vi.advanceTimersByTime(310));
expect(sprite.style.backgroundPosition).toBe("-960px 0px");
act(() => vi.advanceTimersByTime(10)); // 320 ms, not a long hold
expect(sprite.style.backgroundPosition).toBe("0px 0px");
});
it("plays a moment three times, then settles into idle; a condition keeps looping", () => {
const props = { id: "cp-goku", kind: "codex" as const, size: 208, reducedMotion: false };
const { rerender } = render(<PetSprite {...props} mood="happy" onBroken={() => {}} />);
const sprite = screen.getByTestId("pet-sprite");
expect(sprite.dataset.row).toBe("4"); // jumping: 5 frames, 4 × 140 + 280 ms
act(() => vi.advanceTimersByTime(3 * 840 - 1));
expect(sprite.dataset.row).toBe("4");
act(() => vi.advanceTimersByTime(1));
expect(sprite.dataset.row).toBe("0");
rerender(<PetSprite {...props} mood="thinking" onBroken={() => {}} />);
act(() => vi.advanceTimersByTime(10_000));
expect(sprite.dataset.row).toBe("7");
});
it("looks toward the pointer on a v2 sheet, only while idle", () => {
// A v2 atlas is 11 rows tall; the sprite learns that from the loaded image.
vi.stubGlobal(
"Image",
class {
naturalHeight = 2288;
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
set src(_: string) {
this.onload?.();
}
}
);
const props = { id: "cp-goku", kind: "codex" as const, size: 208, reducedMotion: false };
const { rerender } = render(<PetSprite {...props} mood="idle" gaze={12} onBroken={() => {}} />);
const sprite = screen.getByTestId("pet-sprite");
// Pose 12 (270°, left) = row 10, column 4.
expect(sprite.style.backgroundPosition).toBe("-768px -2080px");
expect(sprite.style.backgroundSize).toBe("1536px 2288px");
rerender(<PetSprite {...props} mood="thinking" gaze={12} onBroken={() => {}} />);
expect(sprite.dataset.row).toBe("7");
vi.unstubAllGlobals();
});
it("ignores the pointer on a v1 sheet, which has no gaze rows", () => {
render(
<PetSprite
id="cp-goku"
kind="codex"
mood="idle"
gaze={4}
size={208}
reducedMotion={false}
onBroken={() => {}}
/>
);
expect(screen.getByTestId("pet-sprite").style.backgroundPosition).toBe("0px 0px");
});
it("holds the first frame under reduced motion", () => {
render(
<PetSprite
id="cp-goku"
kind="codex"
mood="idle"
size={208}
reducedMotion
onBroken={() => {}}
/>
);
act(() => vi.advanceTimersByTime(1000));
expect(screen.getByTestId("pet-sprite").style.backgroundPosition).toBe("0px 0px");
});
it("shows an uploaded image still, and reports one that will not load", () => {
const broken = vi.fn();
const { container } = render(
<PetSprite
id="p-aaaaaaaaaaaa"
kind="image"
mood="idle"
size={80}
reducedMotion={false}
onBroken={broken}
/>
);
const img = container.querySelector("img") as HTMLImageElement;
expect(img.getAttribute("src")).toBe("/api/pets/p-aaaaaaaaaaaa/sprite");
fireEvent.error(img);
expect(broken).toHaveBeenCalled();
});
});
describe("PetMenu", () => {
beforeEach(() => {
localStorage.clear();
vi.clearAllMocks();
});
const renderMenu = () =>
render(<PetMenu pet={sagiPrefs.getPet()} size={68} onClose={() => {}} onSettings={() => {}} />);
it("hides the mascot and sizes it", () => {
renderMenu();
fireEvent.change(screen.getByTestId("pet-size"), { target: { value: "250" } });
expect(sagiPrefs.getSize()).toBe(250);
let toasts: ToastItem[] = [];
const off = subscribeToasts((i) => (toasts = i));
fireEvent.click(screen.getByText("Hide pet"));
expect(sagiPrefs.getEnabled()).toBe(false);
// Says where to bring it back.
expect(toasts.some((x) => /Settings → Appearance/.test(x.message))).toBe(true);
off();
});
it("downloads a featured pet on its first pick, then wears it", async () => {
renderMenu();
fireEvent.click(screen.getByTestId("pet-choose"));
fireEvent.click(await screen.findByTestId("pet-featured-goku"));
await waitFor(() => expect(sagiPrefs.getPet()).toEqual({ id: "cp-goku", kind: "codex", name: "Goku" }));
expect(api.pets.installFeatured).toHaveBeenCalledWith("goku");
});
it("deleting the pet being worn puts Sagi back", async () => {
sagiPrefs.setPet({ id: "p-aaaaaaaaaaaa", kind: "image" });
renderMenu();
fireEvent.click(screen.getByTestId("pet-choose"));
fireEvent.click(await screen.findByTestId("pet-delete-p-aaaaaaaaaaaa"));
// Asks first: nothing is deleted on one click.
expect(api.pets.remove).not.toHaveBeenCalled();
answerConfirm(true);
await waitFor(() => expect(sagiPrefs.getPet()).toEqual({ id: "sagi" }));
expect(api.pets.remove).toHaveBeenCalledWith("p-aaaaaaaaaaaa");
});
it("uploads an image and wears it", async () => {
renderMenu();
const file = new File([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], "Dog.png", {
type: "image/png",
});
fireEvent.change(screen.getByTestId("pet-image-input"), { target: { files: [file] } });
await waitFor(() =>
expect(sagiPrefs.getPet()).toEqual({ id: "p-bbbbbbbbbbbb", kind: "image", name: "Dog" })
);
expect(api.pets.upload).toHaveBeenCalledWith(file);
});
});
describe("PetSettings", () => {
beforeEach(() => {
localStorage.clear();
vi.clearAllMocks();
});
it("shows, mutes and sizes the mascot, following the prefs live", async () => {
render(<PetSettings />);
fireEvent.change(screen.getByTestId("pet-settings-size"), { target: { value: "120" } });
expect(sagiPrefs.getSize()).toBe(120);
expect(screen.getByText("120px")).toBeInTheDocument();
fireEvent.click(screen.getByRole("switch", { name: /Speech bubbles/ }));
expect(sagiPrefs.getMuted()).toBe(true);
fireEvent.click(screen.getByRole("switch", { name: /Show Sagi/ }));
expect(sagiPrefs.getEnabled()).toBe(false);
// A change made elsewhere (the right-click menu) shows here at once.
act(() => sagiPrefs.setSize(200));
expect(screen.getByText("200px")).toBeInTheDocument();
});
it("downloads a featured pet on its first pick, wears an own one, deletes it back to Sagi", async () => {
render(<PetSettings />);
fireEvent.click(await screen.findByTestId("pet-card-featured-goku"));
await waitFor(() => expect(sagiPrefs.getPet()).toEqual({ id: "cp-goku", kind: "codex", name: "Goku" }));
expect(api.pets.installFeatured).toHaveBeenCalledWith("goku");
fireEvent.click(screen.getByTestId("pet-card-own-p-aaaaaaaaaaaa"));
expect(sagiPrefs.getPet()).toEqual({ id: "p-aaaaaaaaaaaa", kind: "image", name: "My cat" });
fireEvent.click(screen.getByTestId("pet-card-own-p-aaaaaaaaaaaa-delete"));
answerConfirm(true);
await waitFor(() => expect(sagiPrefs.getPet()).toEqual({ id: "sagi" }));
expect(api.pets.remove).toHaveBeenCalledWith("p-aaaaaaaaaaaa");
});
it("uploads an image and wears it", async () => {
render(<PetSettings />);
const file = new File([new Uint8Array([0x89, 0x50])], "Dog.png", { type: "image/png" });
fireEvent.change(screen.getByTestId("pet-settings-image-input"), { target: { files: [file] } });
await waitFor(() =>
expect(sagiPrefs.getPet()).toEqual({ id: "p-bbbbbbbbbbbb", kind: "image", name: "Dog" })
);
});
it("a cancelled delete keeps the pet", async () => {
render(<PetSettings />);
fireEvent.click(await screen.findByTestId("pet-card-own-p-aaaaaaaaaaaa-delete"));
answerConfirm(false);
await act(async () => {});
expect(api.pets.remove).not.toHaveBeenCalled();
});
it("disables size and bubbles while hidden, and resets to defaults", async () => {
sagiPrefs.setEnabled(false);
sagiPrefs.setSize(200);
sagiPrefs.setMuted(true);
render(<PetSettings />);
expect(screen.getByTestId("pet-settings-size")).toBeDisabled();
expect(screen.getByRole("switch", { name: /Speech bubbles/ })).toBeDisabled();
fireEvent.click(screen.getByRole("button", { name: /Reset to defaults/ }));
answerConfirm(true);
await waitFor(() => expect(sagiPrefs.getEnabled()).toBe(true));
expect(sagiPrefs.getSize()).toBe(68);
expect(sagiPrefs.getMuted()).toBe(false);
expect(sagiPrefs.getPet()).toEqual({ id: "sagi" });
});
});