test(ai-toolbar): reduce layout brittleness and cover shortcuts

This commit is contained in:
2026-04-22 23:49:29 -04:00
parent 6f46925596
commit f2816247c1

View File

@@ -154,6 +154,29 @@ const getButtonOpeningTag = (markup: string, label: string) => {
return openingTagMatch![0];
};
const getClassAttributes = (markup: string) =>
[...markup.matchAll(/class="([^"]+)"/g)].map(([, className]) => className);
const getMatchingClassTokens = (
markup: string,
predicate: (tokens: string[]) => boolean,
) =>
getClassAttributes(markup)
.map((className) => className.split(/\s+/).filter(Boolean))
.filter(predicate);
const createTextareaKeydownEvent = (
overrides: Partial<React.KeyboardEvent<HTMLTextAreaElement>> = {},
) => ({
key: "Enter",
ctrlKey: false,
metaKey: false,
shiftKey: false,
altKey: false,
preventDefault: mock(),
...overrides,
}) as unknown as React.KeyboardEvent<HTMLTextAreaElement>;
const renderToolbarBoundary = async (
overrides: Partial<AIToolbarProps> = {},
) => {
@@ -181,27 +204,70 @@ describe("AI toolbar layout contracts", () => {
test("desktop composer renders a 70/30 split while mobile stays single-column", async () => {
const desktopMarkup = await renderToolbarMarkup();
const mobileMarkup = await renderToolbarMarkup({}, { isMobile: true });
const desktopLayoutTokens = getMatchingClassTokens(
desktopMarkup,
(tokens) => tokens.includes("grid") && tokens.includes("gap-3"),
);
const mobileLayoutTokens = getMatchingClassTokens(
mobileMarkup,
(tokens) => tokens.includes("grid") && tokens.includes("gap-3"),
);
expect(desktopMarkup).toContain(
"grid gap-3 grid-cols-[minmax(0,0.7fr)_minmax(0,0.3fr)]",
expect(desktopLayoutTokens).toHaveLength(1);
expect(desktopLayoutTokens[0]).toEqual(
expect.arrayContaining([
"grid",
"gap-3",
"grid-cols-[minmax(0,0.7fr)_minmax(0,0.3fr)]",
]),
);
expect(desktopMarkup).not.toContain(
"grid gap-3 grid-cols-[minmax(0,1fr)_minmax(0,1fr)]",
expect(mobileLayoutTokens).toHaveLength(1);
expect(mobileLayoutTokens[0]).toEqual(
expect.arrayContaining(["grid", "gap-3"]),
);
expect(mobileMarkup).not.toContain(
expect(mobileLayoutTokens[0]).not.toContain(
"grid-cols-[minmax(0,0.7fr)_minmax(0,0.3fr)]",
);
expect(mobileLayoutTokens[0].some((token) => token.startsWith("grid-cols-"))).toBe(
false,
);
});
test("example prompts render as a masonry-style cluster below the textarea", async () => {
const markup = await renderToolbarMarkup();
const masonryColumns = getMatchingClassTokens(
markup,
(tokens) => tokens.includes("columns-2"),
);
const masonryWrappers = getMatchingClassTokens(
markup,
(tokens) =>
tokens.includes("mb-2") && tokens.includes("break-inside-avoid"),
);
const promptButtons = getMatchingClassTokens(
markup,
(tokens) =>
tokens.includes("justify-start") &&
tokens.includes("whitespace-normal") &&
tokens.includes("rounded-2xl"),
);
expect(markup).toContain("Try:");
expect(markup).toContain('class="columns-2 gap-2"');
expect(markup).toContain('class="mb-2 break-inside-avoid"');
expect(markup).toContain(
"h-auto w-full justify-start text-left whitespace-normal rounded-2xl px-3 py-2 text-[11px] leading-relaxed",
"Lunch with Maya next Thursday at 12:30pm at Toma, remind me 30 minutes before.",
);
expect(markup).toContain(
"Project sync tomorrow from 9am to 10am on Google Meet with a weekly repeat.",
);
expect(markup).toContain(
"Dentist appointment on May 14 at 3pm at Smile Studio, add confirmation #A4821.",
);
expect(masonryColumns).toHaveLength(1);
expect(masonryColumns[0]).toEqual(
expect.arrayContaining(["columns-2", "gap-2"]),
);
expect(masonryWrappers).toHaveLength(3);
expect(promptButtons).toHaveLength(3);
});
test("attachments render as a separate surfaced panel with count badge, picker, and empty state", async () => {
@@ -219,17 +285,23 @@ describe("AI toolbar layout contracts", () => {
const markup = await renderToolbarMarkup({
imagePreviews: ["blob:first", "blob:second"],
});
const previewGridClassNames = [...markup.matchAll(
/class="([^"]*\bmt-3\b[^"]*\bgrid\b[^"]*\bgap-2\b[^"]*)"/g,
)].map(([, className]) => className);
const previewGridTokens = getMatchingClassTokens(
markup,
(tokens) =>
tokens.includes("mt-3") &&
tokens.includes("grid") &&
tokens.includes("gap-2"),
);
const multiColumnPreviewPattern =
/(?:^|\s)(?:[a-z]+:)*(?:grid-cols-(?:\[[^\]]+\]|\S+)|grid-flow-col|auto-cols-(?:\[[^\]]+\]|\S+)|columns-(?:\[[^\]]+\]|\S+)|overflow-x-auto|flex-row)/;
expect(markup).toContain('class="mt-3 grid gap-2"');
expect(markup).toContain("Attached image 1");
expect(markup).toContain("Attached image 2");
expect(previewGridClassNames).toHaveLength(1);
expect(previewGridClassNames[0]).not.toMatch(multiColumnPreviewPattern);
expect(previewGridTokens).toHaveLength(1);
expect(previewGridTokens[0]).toEqual(
expect.arrayContaining(["mt-3", "grid", "gap-2"]),
);
expect(previewGridTokens[0].join(" ")).not.toMatch(multiColumnPreviewPattern);
});
});
@@ -381,6 +453,52 @@ describe("AI toolbar paste capture", () => {
expect(onImagesSelect).toHaveBeenCalledTimes(1);
});
test("Ctrl+Enter triggers AI generation when the prompt has content", async () => {
const onAiCreate = mock();
const textareaProps = await renderToolbarBoundary({
aiPrompt: "Draft a kickoff",
onAiCreate,
});
const event = createTextareaKeydownEvent({ ctrlKey: true });
textareaProps.onKeyDown?.(event);
expect(event.preventDefault).toHaveBeenCalledTimes(1);
expect(onAiCreate).toHaveBeenCalledTimes(1);
});
test("Cmd+Enter triggers AI generation when images are attached", async () => {
const onAiCreate = mock();
const textareaProps = await renderToolbarBoundary({
imagePreviews: ["blob:first"],
onAiCreate,
});
const event = createTextareaKeydownEvent({ metaKey: true });
textareaProps.onKeyDown?.(event);
expect(event.preventDefault).toHaveBeenCalledTimes(1);
expect(onAiCreate).toHaveBeenCalledTimes(1);
});
test("Escape clears the prompt when the textarea has content", async () => {
const setAiPrompt = mock();
const textareaProps = await renderToolbarBoundary({
aiPrompt: "Draft a kickoff",
setAiPrompt,
});
const event = createTextareaKeydownEvent({ key: "Escape" });
textareaProps.onKeyDown?.(event);
expect(event.preventDefault).toHaveBeenCalledTimes(1);
expect(setAiPrompt).toHaveBeenCalledTimes(1);
expect(setAiPrompt).toHaveBeenCalledWith("");
});
test("document paste forwards clipboard images only for non-editable targets", async () => {
const onImagesSelect = mock();
const image = new File(["image-bytes"], "clipboard.png", {