feat(settings): a Pet page for the mascot

Settings -> Pet (/settings/pet, PetSettings.tsx): show/hide, speech
bubbles, size with a live preview, and the pet grid - Sagi, featured
codex-pets.net pets (downloaded on first pick), your own (deletable) -
with image upload and .codex-pet.zip import. The Sagi block leaves
General; the mascot menu's Pet settings opens the page. PetMenu and the
page share components/Sagi/usePets.ts.
This commit is contained in:
2026-10-05 09:54:36 +07:00
parent ad6e80192d
commit 151dc8a480
13 changed files with 463 additions and 196 deletions
@@ -4,7 +4,8 @@
* 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.
* 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";
@@ -60,6 +61,7 @@ vi.mock("../../../lib/api", async (importOriginal) => {
import { api } from "../../../lib/api";
import { PetSprite } from "../PetSprite";
import { PetMenu } from "../PetMenu";
import { PetSettings } from "../../settings/PetSettings";
import { sagiPrefs } from "../prefs";
describe("PetSprite", () => {
@@ -175,3 +177,46 @@ describe("PetMenu", () => {
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" }));
expect(api.pets.installFeatured).toHaveBeenCalledWith("goku");
fireEvent.click(screen.getByTestId("pet-card-own-p-aaaaaaaaaaaa"));
expect(sagiPrefs.getPet()).toEqual({ id: "p-aaaaaaaaaaaa", kind: "image" });
fireEvent.click(screen.getByTestId("pet-card-own-p-aaaaaaaaaaaa-delete"));
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" })
);
});
});