Files
AgentHub/client/src/components/Sagi/__tests__/Pets.test.tsx
T
nntrivi2001 ed1bf20e6b feat(sagi): custom pets — Codex pet bundles, images, featured downloads
The mascot can wear a pet. Right-click it for Hide pet, Size (48-250
px), Choose pet (Sagi, featured, your own - deletable), Upload your
own..., Import .codex-pet bundle..., Find more on codex-pets.net and
Pet settings.... The worn pet and its size are viewer prefs
(agenthub.pet, agenthub.pet.size); the pets live on the server under
<data dir>/pets/<id>/ (server/lib/pets.js, /api/pets).

A Codex pet is the .codex-pet.zip format: pet.json + a 1536x1872
spritesheet.webp, 8x9 frames of 192x208, one row per state.
PetSprite.tsx plays the row of the mascot's mood; an uploaded image
bobs. Only the two bundle files are read, sizes are checked from the
zip directory before inflating, the sheet's WebP header must match,
uploads are raster only (no SVG), ids are made by the server, every
write is same-origin.

The four featured pets (Goku, Bac Ho, Border Collie, 11) are
codex-pets.net uploads with no redistribution licence, so none is
bundled: a pick downloads it from codex-pets.net by a fixed id.
2026-10-03 16:25:42 +07:00

178 lines
5.6 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.
* @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 { sagiPrefs } from "../prefs";
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(130));
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("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);
fireEvent.click(screen.getByText("Hide pet"));
expect(sagiPrefs.getEnabled()).toBe(false);
});
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" }));
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"));
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" })
);
expect(api.pets.upload).toHaveBeenCalledWith(file);
});
});