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]; 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 ( const renderToolbarBoundary = async (
overrides: Partial<AIToolbarProps> = {}, 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 () => { test("desktop composer renders a 70/30 split while mobile stays single-column", async () => {
const desktopMarkup = await renderToolbarMarkup(); const desktopMarkup = await renderToolbarMarkup();
const mobileMarkup = await renderToolbarMarkup({}, { isMobile: true }); 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( expect(desktopLayoutTokens).toHaveLength(1);
"grid gap-3 grid-cols-[minmax(0,0.7fr)_minmax(0,0.3fr)]", expect(desktopLayoutTokens[0]).toEqual(
expect.arrayContaining([
"grid",
"gap-3",
"grid-cols-[minmax(0,0.7fr)_minmax(0,0.3fr)]",
]),
); );
expect(desktopMarkup).not.toContain( expect(mobileLayoutTokens).toHaveLength(1);
"grid gap-3 grid-cols-[minmax(0,1fr)_minmax(0,1fr)]", 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)]", "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 () => { test("example prompts render as a masonry-style cluster below the textarea", async () => {
const markup = await renderToolbarMarkup(); 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("Try:");
expect(markup).toContain('class="columns-2 gap-2"');
expect(markup).toContain('class="mb-2 break-inside-avoid"');
expect(markup).toContain( 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 () => { 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({ const markup = await renderToolbarMarkup({
imagePreviews: ["blob:first", "blob:second"], imagePreviews: ["blob:first", "blob:second"],
}); });
const previewGridClassNames = [...markup.matchAll( const previewGridTokens = getMatchingClassTokens(
/class="([^"]*\bmt-3\b[^"]*\bgrid\b[^"]*\bgap-2\b[^"]*)"/g, markup,
)].map(([, className]) => className); (tokens) =>
tokens.includes("mt-3") &&
tokens.includes("grid") &&
tokens.includes("gap-2"),
);
const multiColumnPreviewPattern = const multiColumnPreviewPattern =
/(?:^|\s)(?:[a-z]+:)*(?:grid-cols-(?:\[[^\]]+\]|\S+)|grid-flow-col|auto-cols-(?:\[[^\]]+\]|\S+)|columns-(?:\[[^\]]+\]|\S+)|overflow-x-auto|flex-row)/; /(?:^|\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 1");
expect(markup).toContain("Attached image 2"); expect(markup).toContain("Attached image 2");
expect(previewGridClassNames).toHaveLength(1); expect(previewGridTokens).toHaveLength(1);
expect(previewGridClassNames[0]).not.toMatch(multiColumnPreviewPattern); 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); 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 () => { test("document paste forwards clipboard images only for non-editable targets", async () => {
const onImagesSelect = mock(); const onImagesSelect = mock();
const image = new File(["image-bytes"], "clipboard.png", { const image = new File(["image-bytes"], "clipboard.png", {