325 lines
12 KiB
TypeScript
325 lines
12 KiB
TypeScript
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import type { CustomPrompt } from "@/lib/types/http";
|
|
import { SettingsSaveProvider } from "./settings-save-provider";
|
|
import { getPromptDraftMeta, PromptsSettings } from "./prompts-settings";
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
createPrompt: vi.fn(),
|
|
deletePrompt: vi.fn(),
|
|
updatePrompt: vi.fn(),
|
|
setPrompts: vi.fn(),
|
|
toast: vi.fn(),
|
|
prompts: [] as CustomPrompt[],
|
|
finePointer: true,
|
|
isMobile: false,
|
|
promptEditor: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("@/hooks/domains/settings/use-custom-prompts", () => ({
|
|
useCustomPrompts: () => ({ loaded: true }),
|
|
}));
|
|
|
|
vi.mock("@/components/state-provider", () => ({
|
|
useAppStore: (selector: (state: unknown) => unknown) =>
|
|
selector({ prompts: { items: mocks.prompts }, setPrompts: mocks.setPrompts }),
|
|
}));
|
|
|
|
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
|
|
useResponsiveBreakpoint: () => ({ isFinePointer: mocks.finePointer, isMobile: mocks.isMobile }),
|
|
}));
|
|
|
|
vi.mock("@/components/toast-provider", () => ({
|
|
useToast: () => ({ toast: mocks.toast }),
|
|
}));
|
|
vi.mock("@/components/settings/settings-prompt-editor", () => ({
|
|
SettingsPromptEditor: (props: Record<string, unknown>) => {
|
|
mocks.promptEditor(props);
|
|
return (
|
|
<textarea
|
|
data-testid={props.testId as string}
|
|
value={props.value as string}
|
|
onChange={(event) => (props.onChange as (value: string) => void)(event.target.value)}
|
|
placeholder="Prompt content"
|
|
/>
|
|
);
|
|
},
|
|
}));
|
|
|
|
vi.mock("@/lib/api", () => ({
|
|
createPrompt: mocks.createPrompt,
|
|
deletePrompt: mocks.deletePrompt,
|
|
updatePrompt: mocks.updatePrompt,
|
|
}));
|
|
|
|
const promptId = "prompt-1";
|
|
const promptName = "Review";
|
|
const promptContent = "Review this change";
|
|
const promptRowTestId = "prompt-list-item";
|
|
const promptContentInputTestId = "prompt-content-input";
|
|
const promptDeleteButtonTestId = "prompt-delete-button";
|
|
const promptDeletePopoverTestId = "prompt-delete-confirm-popover";
|
|
const promptDeleteInlineTestId = "prompt-delete-inline-confirmation";
|
|
const promptDeleteConfirmTestId = "prompt-delete-confirm";
|
|
const deleteFailureMessage = "delete failed";
|
|
const touchConfirmHeightClass = "h-11";
|
|
const touchConfirmWidthClass = "min-w-11";
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mocks.prompts = [];
|
|
mocks.finePointer = true;
|
|
mocks.isMobile = false;
|
|
mocks.setPrompts.mockImplementation((next: CustomPrompt[]) => {
|
|
mocks.prompts = next;
|
|
});
|
|
mocks.createPrompt.mockResolvedValue({
|
|
id: promptId,
|
|
name: promptName,
|
|
content: promptContent,
|
|
builtin: false,
|
|
});
|
|
});
|
|
|
|
afterEach(cleanup);
|
|
|
|
const existingPrompt: CustomPrompt = {
|
|
id: promptId,
|
|
name: promptName,
|
|
content: promptContent,
|
|
builtin: false,
|
|
created_at: "2026-08-20T10:00:00Z",
|
|
updated_at: "2026-08-20T10:00:00Z",
|
|
};
|
|
|
|
function renderPromptSettings() {
|
|
mocks.prompts = [existingPrompt];
|
|
render(
|
|
<SettingsSaveProvider>
|
|
<PromptsSettings />
|
|
</SettingsSaveProvider>,
|
|
);
|
|
}
|
|
|
|
describe("PromptsSettings deletion confirmation", () => {
|
|
it("keeps the phone editor draft and delete trigger behind a confirmation sheet", async () => {
|
|
mocks.isMobile = true;
|
|
mocks.finePointer = false;
|
|
renderPromptSettings();
|
|
const row = screen.getByTestId(promptRowTestId);
|
|
fireEvent.click(within(row).getByTestId("prompt-edit-button"));
|
|
const editor = within(row).getByTestId(promptContentInputTestId) as HTMLTextAreaElement;
|
|
fireEvent.change(editor, { target: { value: "Keep the mobile draft" } });
|
|
const trigger = within(row).getByTestId(promptDeleteButtonTestId);
|
|
fireEvent.click(trigger);
|
|
const sheet = screen.getByRole("dialog", { name: "Delete prompt" });
|
|
expect(sheet.getAttribute("data-slot")).toBe("drawer-content");
|
|
expect(trigger.isConnected).toBe(true);
|
|
fireEvent.click(within(sheet).getByRole("button", { name: "Cancel" }));
|
|
await waitFor(() => expect(document.activeElement).toBe(trigger));
|
|
expect(editor.value).toBe("Keep the mobile draft");
|
|
expect(mocks.deletePrompt).not.toHaveBeenCalled();
|
|
fireEvent.click(trigger);
|
|
fireEvent.click(screen.getByTestId(promptDeleteConfirmTestId));
|
|
await waitFor(() =>
|
|
expect(mocks.deletePrompt).toHaveBeenCalledExactlyOnceWith(promptId, { cache: "no-store" }),
|
|
);
|
|
});
|
|
it("cancels an anchored delete without losing the prompt editor draft", async () => {
|
|
renderPromptSettings();
|
|
|
|
const row = screen.getByTestId(promptRowTestId);
|
|
fireEvent.click(within(row).getByTestId("prompt-edit-button"));
|
|
fireEvent.change(within(row).getByTestId(promptContentInputTestId), {
|
|
target: { value: "Keep this draft" },
|
|
});
|
|
fireEvent.click(within(row).getByTestId(promptDeleteButtonTestId));
|
|
|
|
const confirmation = screen.getByTestId(promptDeletePopoverTestId);
|
|
expect(screen.queryByRole("alertdialog")).toBeNull();
|
|
fireEvent.click(within(confirmation).getByRole("button", { name: "Cancel" }));
|
|
|
|
expect(mocks.deletePrompt).not.toHaveBeenCalled();
|
|
expect((within(row).getByTestId(promptContentInputTestId) as HTMLTextAreaElement).value).toBe(
|
|
"Keep this draft",
|
|
);
|
|
});
|
|
|
|
it("morphs the row action into touch-sized inline confirmation on coarse pointers", () => {
|
|
mocks.finePointer = false;
|
|
renderPromptSettings();
|
|
|
|
const row = screen.getByTestId(promptRowTestId);
|
|
fireEvent.click(within(row).getByTestId(promptDeleteButtonTestId));
|
|
|
|
const inline = screen.getByTestId(promptDeleteInlineTestId);
|
|
expect(screen.queryByTestId(promptDeletePopoverTestId)).toBeNull();
|
|
expect(within(inline).getByTestId(promptDeleteConfirmTestId).className).toContain(
|
|
touchConfirmHeightClass,
|
|
);
|
|
expect(within(inline).getByTestId(promptDeleteConfirmTestId).className).toContain(
|
|
touchConfirmWidthClass,
|
|
);
|
|
});
|
|
|
|
it("disables inline deletion while a prompt update is saving", async () => {
|
|
mocks.finePointer = false;
|
|
mocks.updatePrompt.mockReturnValue(new Promise<CustomPrompt>(() => undefined));
|
|
renderPromptSettings();
|
|
|
|
const row = screen.getByTestId(promptRowTestId);
|
|
fireEvent.click(within(row).getByTestId("prompt-edit-button"));
|
|
fireEvent.change(within(row).getByTestId(promptContentInputTestId), {
|
|
target: { value: "Save this draft" },
|
|
});
|
|
fireEvent.click(within(row).getByTestId(promptDeleteButtonTestId));
|
|
fireEvent.click(screen.getByRole("button", { name: "Save changes" }));
|
|
|
|
await waitFor(() => expect(mocks.updatePrompt).toHaveBeenCalledTimes(1));
|
|
const confirm = within(screen.getByTestId(promptDeleteInlineTestId)).getByTestId(
|
|
promptDeleteConfirmTestId,
|
|
);
|
|
expect(confirm.hasAttribute("disabled")).toBe(true);
|
|
fireEvent.click(confirm);
|
|
expect(mocks.deletePrompt).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("restores focus to the coarse-pointer delete button after Escape", async () => {
|
|
mocks.finePointer = false;
|
|
renderPromptSettings();
|
|
|
|
const row = screen.getByTestId(promptRowTestId);
|
|
fireEvent.click(within(row).getByTestId(promptDeleteButtonTestId));
|
|
fireEvent.keyDown(screen.getByTestId(promptDeleteInlineTestId), { key: "Escape" });
|
|
|
|
await waitFor(() =>
|
|
expect(document.activeElement).toBe(within(row).getByTestId(promptDeleteButtonTestId)),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("PromptsSettings deletion requests", () => {
|
|
it("closes before dispatching one delete request", async () => {
|
|
const deleteDeferred = new Promise<void>(() => undefined);
|
|
mocks.deletePrompt.mockReturnValue(deleteDeferred);
|
|
renderPromptSettings();
|
|
|
|
const row = screen.getByTestId(promptRowTestId);
|
|
fireEvent.click(within(row).getByTestId(promptDeleteButtonTestId));
|
|
fireEvent.click(
|
|
within(screen.getByTestId(promptDeletePopoverTestId)).getByTestId(promptDeleteConfirmTestId),
|
|
);
|
|
|
|
expect(screen.queryByTestId(promptDeletePopoverTestId)).toBeNull();
|
|
await waitFor(() => expect(mocks.deletePrompt).toHaveBeenCalledTimes(1));
|
|
expect(mocks.deletePrompt).toHaveBeenCalledWith("prompt-1", { cache: "no-store" });
|
|
});
|
|
|
|
it("closes before dispatching one delete request on coarse pointers", async () => {
|
|
mocks.finePointer = false;
|
|
const deleteDeferred = new Promise<void>(() => undefined);
|
|
mocks.deletePrompt.mockReturnValue(deleteDeferred);
|
|
renderPromptSettings();
|
|
|
|
const row = screen.getByTestId(promptRowTestId);
|
|
fireEvent.click(within(row).getByTestId(promptDeleteButtonTestId));
|
|
fireEvent.click(
|
|
within(screen.getByTestId(promptDeleteInlineTestId)).getByTestId(promptDeleteConfirmTestId),
|
|
);
|
|
|
|
expect(screen.queryByTestId(promptDeleteInlineTestId)).toBeNull();
|
|
await waitFor(() => expect(mocks.deletePrompt).toHaveBeenCalledTimes(1));
|
|
expect(mocks.deletePrompt).toHaveBeenCalledWith("prompt-1", { cache: "no-store" });
|
|
});
|
|
|
|
it("keeps the prompt and reports localized failure feedback when deletion fails", async () => {
|
|
mocks.deletePrompt.mockRejectedValue(new Error(deleteFailureMessage));
|
|
renderPromptSettings();
|
|
|
|
const row = screen.getByTestId(promptRowTestId);
|
|
fireEvent.click(within(row).getByTestId(promptDeleteButtonTestId));
|
|
fireEvent.click(
|
|
within(screen.getByTestId(promptDeletePopoverTestId)).getByTestId(promptDeleteConfirmTestId),
|
|
);
|
|
|
|
await waitFor(() => expect(mocks.toast).toHaveBeenCalledTimes(1));
|
|
expect(mocks.toast).toHaveBeenCalledWith({
|
|
title: "Couldn't delete prompt",
|
|
description: deleteFailureMessage,
|
|
variant: "error",
|
|
});
|
|
expect(screen.getByTestId(promptRowTestId)).toBeTruthy();
|
|
});
|
|
|
|
it("keeps the prompt and reports failure feedback on coarse pointers", async () => {
|
|
mocks.finePointer = false;
|
|
mocks.deletePrompt.mockRejectedValue(new Error(deleteFailureMessage));
|
|
renderPromptSettings();
|
|
|
|
const row = screen.getByTestId(promptRowTestId);
|
|
fireEvent.click(within(row).getByTestId(promptDeleteButtonTestId));
|
|
fireEvent.click(
|
|
within(screen.getByTestId(promptDeleteInlineTestId)).getByTestId(promptDeleteConfirmTestId),
|
|
);
|
|
|
|
await waitFor(() => expect(mocks.toast).toHaveBeenCalledTimes(1));
|
|
expect(mocks.toast).toHaveBeenCalledWith({
|
|
title: "Couldn't delete prompt",
|
|
description: deleteFailureMessage,
|
|
variant: "error",
|
|
});
|
|
expect(screen.getByTestId(promptRowTestId)).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
describe("PromptsSettings coordinated creation", () => {
|
|
it("treats an opened create form as a dirty route draft", () => {
|
|
expect(getPromptDraftMeta([], null, true, { name: "", content: "" })).toEqual({
|
|
isDirty: true,
|
|
revision: 'new:{"name":"","content":""}',
|
|
});
|
|
});
|
|
|
|
it("creates a prompt only when the floating route Save is pressed", async () => {
|
|
render(
|
|
<SettingsSaveProvider>
|
|
<PromptsSettings />
|
|
</SettingsSaveProvider>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "Add prompt" }));
|
|
|
|
expect(screen.getByTestId("prompt-create-form").getAttribute("data-settings-dirty")).toBe(
|
|
"true",
|
|
);
|
|
expect(screen.queryByTestId("prompt-submit")).toBeNull();
|
|
expect(screen.getByTestId("prompt-create-button").hasAttribute("disabled")).toBe(true);
|
|
expect(mocks.createPrompt).not.toHaveBeenCalled();
|
|
expect(screen.getByRole("button", { name: "Save changes" }).hasAttribute("disabled")).toBe(
|
|
true,
|
|
);
|
|
|
|
fireEvent.change(screen.getByPlaceholderText("Prompt name"), {
|
|
target: { value: promptName },
|
|
});
|
|
fireEvent.change(screen.getByTestId(promptContentInputTestId), {
|
|
target: { value: promptContent },
|
|
});
|
|
expect(mocks.promptEditor).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
promptReferences: true,
|
|
testId: promptContentInputTestId,
|
|
}),
|
|
);
|
|
fireEvent.click(screen.getByRole("button", { name: "Save changes" }));
|
|
|
|
await waitFor(() =>
|
|
expect(mocks.createPrompt).toHaveBeenCalledWith(
|
|
{ name: promptName, content: promptContent },
|
|
{ cache: "no-store" },
|
|
),
|
|
);
|
|
});
|
|
});
|