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];
|
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([
|
||||||
expect(desktopMarkup).not.toContain(
|
"grid",
|
||||||
"grid gap-3 grid-cols-[minmax(0,1fr)_minmax(0,1fr)]",
|
"gap-3",
|
||||||
);
|
|
||||||
expect(mobileMarkup).not.toContain(
|
|
||||||
"grid-cols-[minmax(0,0.7fr)_minmax(0,0.3fr)]",
|
"grid-cols-[minmax(0,0.7fr)_minmax(0,0.3fr)]",
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(mobileLayoutTokens).toHaveLength(1);
|
||||||
|
expect(mobileLayoutTokens[0]).toEqual(
|
||||||
|
expect.arrayContaining(["grid", "gap-3"]),
|
||||||
|
);
|
||||||
|
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 () => {
|
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", {
|
||||||
|
|||||||
Reference in New Issue
Block a user