/** * @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ĩ */ 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>(); 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( {}} /> ); 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( {}} /> ); 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(); expect(screen.getByTestId("pet-sprite").dataset.row).toBe("2"); rerender(); expect(screen.getByTestId("pet-sprite").dataset.row).toBe("1"); rerender(); expect(screen.getByTestId("pet-sprite").dataset.row).toBe("3"); rerender(); 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( {}} /> ); 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( {}} />); 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( {}} />); 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( {}} />); 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( {}} />); expect(sprite.dataset.row).toBe("7"); vi.unstubAllGlobals(); }); it("ignores the pointer on a v1 sheet, which has no gaze rows", () => { render( {}} /> ); expect(screen.getByTestId("pet-sprite").style.backgroundPosition).toBe("0px 0px"); }); it("holds the first frame under reduced motion", () => { render( {}} /> ); 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( ); 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( {}} 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(); 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(); 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(); 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(); 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(); 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" }); }); });