test(ai-toolbar): reduce layout brittleness and cover shortcuts
This commit is contained in:
@@ -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", {
|
||||
|
||||
Reference in New Issue
Block a user