ed1bf20e6b
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.
178 lines
5.6 KiB
TypeScript
178 lines
5.6 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.
|
||
* @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);
|
||
});
|
||
});
|