0cb5e41faf
- 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>
359 lines
13 KiB
TypeScript
359 lines
13 KiB
TypeScript
/**
|
||
* @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" });
|
||
});
|
||
});
|