6715fbd971
- EditorModal loads once per path (a parent re-render no longer reloads the file over the user's typing), refuses Save after a failed load, opens a truncated (>256 KB) file read-only, and asks before overwriting a file that changed on disk or could not be re-read. - Settings/Hooks editors unset only keys from the snapshot taken at Edit, warn when the file changed underneath, keep a renamed key's value, and refuse a new key named like a redacted one (env) or a read-only hook event, which used to replace the secret/hooks. - Project scope shows its resolved folder and is disabled inside the plugin cache. Account-profile, pricing-row and pet deletes confirm; two-click actions disarm after 3 s; unsaved drafts ask before closing. - Search indexes every Claude Code / Codex sub-tab, opens it via ?tab= and scrolls to + highlights the row; the active config tab is in the URL. - General splits into General / Data / About; Pricing moves to a Cost group with one shared load; config tabs group as Content / Extensions / Config with arrow-key roving; Promise.allSettled per tab. - Data: browser-preferences export/import (allowlisted keys), per-section reset, housekeeping dry-run first, clear-all behind a typed phrase. Server: purge days must be a whole number >= 1 (INVALID_PURGE_DAYS) and dry_run returns exactly what the real run would delete. - Alerts can be edited; webhooks/alerts/remote-source failures toast; copies go through copyText; modals get dialog roles and useDialog. - Sagi: Ctrl/Cmd+B only when shown and not typing, default spot clear of row controls, arrow keys move it, reduced motion honoured; the dead /run launch link goes to the workspace.
265 lines
9.4 KiB
TypeScript
265 lines
9.4 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(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);
|
||
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 → Pet/.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" }));
|
||
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" })
|
||
);
|
||
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"));
|
||
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" })
|
||
);
|
||
});
|
||
|
||
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" });
|
||
});
|
||
});
|