305 lines
12 KiB
TypeScript
305 lines
12 KiB
TypeScript
import { createRef, useEffect } from "react";
|
|
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import type { StoreApi } from "zustand";
|
|
import { StateProvider, useAppStoreApi } from "@/components/state-provider";
|
|
import type { AppState } from "@/lib/state/store";
|
|
import type { RichTextInputHandle } from "./task/chat/rich-text-input";
|
|
import { TaskPromptReferenceEditor } from "./task-prompt-reference-editor";
|
|
|
|
const PROMPT = {
|
|
id: "prompt-1",
|
|
name: "Daily Summary",
|
|
content: "Summarize the current work.",
|
|
builtin: false,
|
|
created_at: "2026-09-12T00:00:00Z",
|
|
updated_at: "2026-09-12T00:00:00Z",
|
|
};
|
|
const PROMPT_REFERENCE_REMOVE_TEST_ID = "task-prompt-reference-remove";
|
|
const PROMPT_REFERENCE_TEST_ID = "task-prompt-reference";
|
|
const PROMPT_MENTION_TEST_ID = "custom-prompt-mention";
|
|
const PROMPT_REFERENCE_SELECTOR = '[data-testid="task-prompt-reference"]';
|
|
const touchState = vi.hoisted(() => ({ enabled: false }));
|
|
|
|
vi.mock("@/hooks/use-compact-task-chrome", () => ({
|
|
useTouchDrawer: () => touchState.enabled,
|
|
}));
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
touchState.enabled = false;
|
|
});
|
|
|
|
function PromptStoreCapture({ storeRef }: { storeRef: { current: StoreApi<AppState> | null } }) {
|
|
const store = useAppStoreApi();
|
|
useEffect(() => {
|
|
storeRef.current = store;
|
|
}, [store, storeRef]);
|
|
return null;
|
|
}
|
|
|
|
function renderEditor(
|
|
value: string,
|
|
ref = createRef<RichTextInputHandle>(),
|
|
prompts = [PROMPT],
|
|
onChange: (nextValue: string) => void = () => undefined,
|
|
) {
|
|
const storeRef: { current: StoreApi<AppState> | null } = { current: null };
|
|
return {
|
|
ref,
|
|
storeRef,
|
|
...render(
|
|
<StateProvider initialState={{ prompts: { items: prompts, loaded: true, loading: false } }}>
|
|
<PromptStoreCapture storeRef={storeRef} />
|
|
<TaskPromptReferenceEditor
|
|
ref={ref}
|
|
value={value}
|
|
onChange={onChange}
|
|
placeholder="Describe the task"
|
|
/>
|
|
</StateProvider>,
|
|
),
|
|
};
|
|
}
|
|
|
|
describe("TaskPromptReferenceEditor basic behavior", () => {
|
|
it("renders recognized aliases as editable reference chips and preserves plain text", async () => {
|
|
const { ref } = renderEditor(`Before @${PROMPT.name}`);
|
|
|
|
expect(
|
|
screen.getByTestId("task-description-input").getAttribute("data-prompt-reference-editor"),
|
|
).toBe("true");
|
|
expect(screen.getByRole("textbox", { name: "Describe the task" })).toBeTruthy();
|
|
await waitFor(() =>
|
|
expect(screen.getByTestId(PROMPT_MENTION_TEST_ID).textContent).toBe(`@${PROMPT.name}`),
|
|
);
|
|
expect(screen.getByTestId(PROMPT_REFERENCE_REMOVE_TEST_ID)).toBeTruthy();
|
|
expect(ref.current?.getValue()).toBe(`Before @${PROMPT.name}`);
|
|
});
|
|
|
|
it("removes only the selected occurrence through its visible action", async () => {
|
|
const onChange = vi.fn();
|
|
const ref = createRef<RichTextInputHandle>();
|
|
render(
|
|
<StateProvider initialState={{ prompts: { items: [PROMPT], loaded: true, loading: false } }}>
|
|
<TaskPromptReferenceEditor
|
|
ref={ref}
|
|
value={`@${PROMPT.name} and @${PROMPT.name}`}
|
|
onChange={onChange}
|
|
placeholder="Describe the task"
|
|
/>
|
|
</StateProvider>,
|
|
);
|
|
|
|
await waitFor(() =>
|
|
expect(screen.getAllByTestId(PROMPT_REFERENCE_REMOVE_TEST_ID)).toHaveLength(2),
|
|
);
|
|
act(() => screen.getAllByTestId(PROMPT_REFERENCE_REMOVE_TEST_ID)[0].click());
|
|
|
|
await waitFor(() => {
|
|
expect(ref.current?.getValue()).toBe(` and @${PROMPT.name}`);
|
|
expect(onChange).toHaveBeenCalledWith(` and @${PROMPT.name}`);
|
|
expect(screen.queryByText(PROMPT.content)).toBeNull();
|
|
});
|
|
});
|
|
|
|
it("renders editable references as a compact shell with contained controls", async () => {
|
|
renderEditor(`Before @${PROMPT.name}`);
|
|
|
|
await waitFor(() => expect(screen.getByTestId(PROMPT_REFERENCE_REMOVE_TEST_ID)).toBeTruthy());
|
|
|
|
const shell = screen.getByTestId(PROMPT_REFERENCE_TEST_ID);
|
|
const mention = screen.getByTestId(PROMPT_MENTION_TEST_ID);
|
|
const label = screen.getByTestId("custom-prompt-mention-label");
|
|
const remove = screen.getByTestId(PROMPT_REFERENCE_REMOVE_TEST_ID);
|
|
const borderedElements = [
|
|
shell,
|
|
...Array.from(shell.querySelectorAll<HTMLElement>("[class]")),
|
|
].filter((element) => element.className.includes("border-emerald-300/35"));
|
|
|
|
expect(shell.className).toContain("h-6");
|
|
expect(shell.className).toContain("min-w-0");
|
|
expect(shell.className).toContain("box-border");
|
|
expect(borderedElements).toHaveLength(1);
|
|
expect(mention.className).toContain("text-xs");
|
|
expect(mention.className).toContain("min-w-0");
|
|
expect(label.className).toContain("min-w-0");
|
|
expect(label.className).toContain("truncate");
|
|
expect(remove.className).toContain("h-5");
|
|
expect(remove.className).toContain("focus-visible");
|
|
expect(mention.closest(PROMPT_REFERENCE_SELECTOR)).toBe(shell);
|
|
expect(remove.closest(PROMPT_REFERENCE_SELECTOR)).toBe(shell);
|
|
});
|
|
|
|
it("keeps a long reference label accessible while reserving removal space", async () => {
|
|
const longName = "a-very-long-prompt-name-that-must-stay-inside-the-editor";
|
|
const longPrompt = { ...PROMPT, name: longName };
|
|
renderEditor(`@${longName}`, createRef<RichTextInputHandle>(), [longPrompt]);
|
|
|
|
await waitFor(() => expect(screen.getByTestId(PROMPT_MENTION_TEST_ID)).toBeTruthy());
|
|
|
|
const mention = screen.getByTestId(PROMPT_MENTION_TEST_ID);
|
|
const label = screen.getByTestId("custom-prompt-mention-label");
|
|
expect(mention.getAttribute("aria-label")).toBe(`Custom prompt: ${longName}`);
|
|
expect(label.className).toContain("truncate");
|
|
expect(label.className).toContain("min-w-0");
|
|
expect(screen.getByTestId(PROMPT_REFERENCE_REMOVE_TEST_ID)).toBeTruthy();
|
|
});
|
|
|
|
it("keeps preview and removal as separate touch-sized targets inside the shell", async () => {
|
|
touchState.enabled = true;
|
|
renderEditor(`@${PROMPT.name}`);
|
|
|
|
await waitFor(() => expect(screen.getByTestId(PROMPT_REFERENCE_REMOVE_TEST_ID)).toBeTruthy());
|
|
|
|
const shell = screen.getByTestId(PROMPT_REFERENCE_TEST_ID);
|
|
const mention = screen.getByTestId(PROMPT_MENTION_TEST_ID);
|
|
const remove = screen.getByTestId(PROMPT_REFERENCE_REMOVE_TEST_ID);
|
|
|
|
expect(shell.className).toContain("min-h-11");
|
|
expect(shell.className).toContain("box-border");
|
|
expect(shell.className).not.toContain("box-content");
|
|
expect(mention.className).toContain("h-11");
|
|
expect(mention.className).toContain("min-w-11");
|
|
expect(remove.className).toContain("h-11");
|
|
expect(remove.className).toContain("min-w-11");
|
|
expect(mention.closest(PROMPT_REFERENCE_SELECTOR)).toBe(shell);
|
|
expect(remove.closest(PROMPT_REFERENCE_SELECTOR)).toBe(shell);
|
|
});
|
|
});
|
|
|
|
describe("TaskPromptReferenceEditor reconciliation", () => {
|
|
it("recognizes every alias when prompts load after a multi-paragraph draft", async () => {
|
|
const draft = [`first @${PROMPT.name}`, `second @${PROMPT.name}`, `third @${PROMPT.name}`].join(
|
|
"\n",
|
|
);
|
|
const { ref, storeRef } = renderEditor(draft, createRef<RichTextInputHandle>(), []);
|
|
|
|
await waitFor(() => expect(storeRef.current).not.toBeNull());
|
|
act(() => storeRef.current!.getState().setPrompts([PROMPT]));
|
|
|
|
await waitFor(() =>
|
|
expect(screen.getAllByTestId(PROMPT_REFERENCE_REMOVE_TEST_ID)).toHaveLength(3),
|
|
);
|
|
expect(ref.current?.getValue()).toBe(draft);
|
|
});
|
|
|
|
it("removes every alias when a multi-paragraph prompt store is cleared", async () => {
|
|
const draft = [`first @${PROMPT.name}`, `second @${PROMPT.name}`, `third @${PROMPT.name}`].join(
|
|
"\n",
|
|
);
|
|
const { ref, storeRef } = renderEditor(draft);
|
|
|
|
await waitFor(() =>
|
|
expect(screen.getAllByTestId(PROMPT_REFERENCE_REMOVE_TEST_ID)).toHaveLength(3),
|
|
);
|
|
act(() => storeRef.current!.getState().setPrompts([]));
|
|
|
|
await waitFor(() => expect(screen.queryByTestId(PROMPT_REFERENCE_REMOVE_TEST_ID)).toBeNull());
|
|
expect(ref.current?.getValue()).toBe(draft);
|
|
});
|
|
|
|
it("preserves a Unicode collapsed selection when aliases are reconciled", async () => {
|
|
const draft = `你好😀 Before @${PROMPT.name} after\nsecond @${PROMPT.name}`;
|
|
const caret = "你好😀 Befo".length;
|
|
const { ref, storeRef } = renderEditor(draft, createRef<RichTextInputHandle>(), []);
|
|
|
|
await waitFor(() => expect(ref.current).not.toBeNull());
|
|
act(() => ref.current!.setSelectionRange(caret, caret));
|
|
expect(ref.current?.getSelectionStart()).toBe(caret);
|
|
act(() => storeRef.current!.getState().setPrompts([PROMPT]));
|
|
|
|
await waitFor(() =>
|
|
expect(screen.getAllByTestId(PROMPT_REFERENCE_REMOVE_TEST_ID)).toHaveLength(2),
|
|
);
|
|
expect(ref.current?.getSelectionStart()).toBe(caret);
|
|
expect(ref.current?.getSelectionEnd()).toBe(caret);
|
|
});
|
|
|
|
it("preserves a range selection in ordinary text when aliases are reconciled", async () => {
|
|
const draft = `Before @${PROMPT.name} after\nother @${PROMPT.name}`;
|
|
const start = 2;
|
|
const end = 5;
|
|
const { ref, storeRef } = renderEditor(draft, createRef<RichTextInputHandle>(), []);
|
|
|
|
await waitFor(() => expect(ref.current).not.toBeNull());
|
|
act(() => ref.current!.setSelectionRange(start, end));
|
|
act(() => storeRef.current!.getState().setPrompts([PROMPT]));
|
|
|
|
await waitFor(() =>
|
|
expect(screen.getAllByTestId(PROMPT_REFERENCE_REMOVE_TEST_ID)).toHaveLength(2),
|
|
);
|
|
expect(ref.current?.getSelectionStart()).toBe(start);
|
|
expect(ref.current?.getSelectionEnd()).toBe(end);
|
|
});
|
|
|
|
it("keeps fenced code and link destinations plain across mount, blur, and store refresh", async () => {
|
|
const draft = [
|
|
"```",
|
|
`@${PROMPT.name}`,
|
|
"```",
|
|
`[label @${PROMPT.name}](url "title @${PROMPT.name}")`,
|
|
`after @${PROMPT.name}`,
|
|
].join("\n");
|
|
const { ref, storeRef } = renderEditor(draft);
|
|
|
|
await waitFor(() =>
|
|
expect(screen.getAllByTestId(PROMPT_REFERENCE_REMOVE_TEST_ID)).toHaveLength(2),
|
|
);
|
|
act(() => {
|
|
ref.current!.focus();
|
|
ref.current!.blur();
|
|
});
|
|
expect(screen.getAllByTestId(PROMPT_REFERENCE_REMOVE_TEST_ID)).toHaveLength(2);
|
|
|
|
act(() => storeRef.current!.getState().setPrompts([]));
|
|
await waitFor(() => expect(screen.queryByTestId(PROMPT_REFERENCE_REMOVE_TEST_ID)).toBeNull());
|
|
act(() => storeRef.current!.getState().setPrompts([PROMPT]));
|
|
await waitFor(() =>
|
|
expect(screen.getAllByTestId(PROMPT_REFERENCE_REMOVE_TEST_ID)).toHaveLength(2),
|
|
);
|
|
expect(ref.current?.getValue()).toBe(draft);
|
|
});
|
|
|
|
it("does not add prompt reconciliation to the undo history", async () => {
|
|
const draft = `Before @${PROMPT.name}`;
|
|
const { ref, storeRef } = renderEditor(draft, createRef<RichTextInputHandle>(), []);
|
|
|
|
await waitFor(() => expect(ref.current).not.toBeNull());
|
|
act(() => {
|
|
ref.current!.setSelectionRange(draft.length, draft.length);
|
|
ref.current!.insertText(" added", draft.length, draft.length);
|
|
});
|
|
expect(ref.current?.getValue()).toBe(`${draft} added`);
|
|
|
|
act(() => storeRef.current!.getState().setPrompts([PROMPT]));
|
|
await waitFor(() => expect(screen.getByTestId(PROMPT_REFERENCE_REMOVE_TEST_ID)).toBeTruthy());
|
|
fireEvent.keyDown(screen.getByTestId("task-description-input"), {
|
|
key: "z",
|
|
ctrlKey: true,
|
|
});
|
|
|
|
await waitFor(() => expect(ref.current?.getValue()).toBe(draft));
|
|
});
|
|
});
|
|
|
|
describe("TaskPromptReferenceEditor insertion", () => {
|
|
it("inserts literal plain text, including HTML-like content, in synchronous calls", async () => {
|
|
const ref = createRef<RichTextInputHandle>();
|
|
const onChange = vi.fn();
|
|
renderEditor("", ref, [PROMPT], onChange);
|
|
|
|
await waitFor(() => expect(ref.current).not.toBeNull());
|
|
const first = "<p>hello</p> &\n first";
|
|
act(() => {
|
|
ref.current!.setSelectionRange(0, 0);
|
|
ref.current!.insertText(first, 0, 0);
|
|
const cursor = ref.current!.getSelectionStart();
|
|
ref.current!.insertText(" second", cursor, cursor);
|
|
});
|
|
|
|
await waitFor(() => expect(ref.current?.getValue()).toBe(`${first} second`));
|
|
expect(onChange).toHaveBeenLastCalledWith(`${first} second`);
|
|
});
|
|
});
|