test(ai-toolbar): assert runtime layout and action states

This commit is contained in:
2026-04-22 23:41:26 -04:00
parent 62e6be5742
commit 6f46925596

View File

@@ -1,10 +1,7 @@
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import { readFileSync } from "node:fs";
import * as React from "react";
import { renderToStaticMarkup } from "react-dom/server";
const readToolbarSource = () => readFileSync("src/components/ai-toolbar.tsx", "utf8");
type AIToolbarProps = {
adminAiEnabled: boolean;
aiEnabled: boolean;
@@ -128,7 +125,12 @@ const renderToolbar = async (overrides: Partial<AIToolbarProps> = {}) => {
const renderToolbarMarkup = async (
overrides: Partial<AIToolbarProps> = {},
{ isMobile = false }: { isMobile?: boolean } = {},
) => {
mock.module("@/hooks/use-mobile", () => ({
useIsMobile: () => isMobile,
}));
const { AIToolbar } = await import("@/components/ai-toolbar");
return renderToStaticMarkup(
@@ -136,6 +138,22 @@ const renderToolbarMarkup = async (
);
};
const escapeForRegex = (value: string) =>
value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const getButtonOpeningTag = (markup: string, label: string) => {
const buttonMatch = markup.match(
new RegExp(
`<button\\b[^>]*>(?:(?!<\\/button>)[\\s\\S])*?${escapeForRegex(label)}(?:(?!<\\/button>)[\\s\\S])*?<\\/button>`,
),
);
const openingTagMatch = buttonMatch?.[0].match(/^<button\b[^>]*>/);
expect(openingTagMatch).toBeDefined();
return openingTagMatch![0];
};
const renderToolbarBoundary = async (
overrides: Partial<AIToolbarProps> = {},
) => {
@@ -160,27 +178,29 @@ const renderToolbarBoundary = async (
};
describe("AI toolbar layout contracts", () => {
test("desktop composer uses a 70/30 split driven by the isMobile branch", () => {
const source = readToolbarSource();
test("desktop composer renders a 70/30 split while mobile stays single-column", async () => {
const desktopMarkup = await renderToolbarMarkup();
const mobileMarkup = await renderToolbarMarkup({}, { isMobile: true });
expect(source).toContain("isMobile");
expect(source).toContain('? "grid gap-3"');
expect(source).toContain(
': "grid gap-3 grid-cols-[minmax(0,0.7fr)_minmax(0,0.3fr)]"',
expect(desktopMarkup).toContain(
"grid gap-3 grid-cols-[minmax(0,0.7fr)_minmax(0,0.3fr)]",
);
expect(source).not.toContain(
': "grid gap-3 grid-cols-[minmax(0,1fr)_minmax(0,1fr)]"',
expect(desktopMarkup).not.toContain(
"grid gap-3 grid-cols-[minmax(0,1fr)_minmax(0,1fr)]",
);
expect(mobileMarkup).not.toContain(
"grid-cols-[minmax(0,0.7fr)_minmax(0,0.3fr)]",
);
});
test("example prompts render as a two-column masonry-style cluster below the textarea", () => {
const source = readToolbarSource();
test("example prompts render as a masonry-style cluster below the textarea", async () => {
const markup = await renderToolbarMarkup();
expect(source).toContain("Try:");
expect(source).toContain('className="columns-2 gap-2"');
expect(source).toContain('className="mb-2 break-inside-avoid"');
expect(source).toContain(
'className="h-auto w-full justify-start text-left whitespace-normal rounded-2xl px-3 py-2 text-[11px] leading-relaxed"',
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",
);
});
@@ -195,15 +215,19 @@ describe("AI toolbar layout contracts", () => {
);
});
test("attachment previews stack one per row instead of rendering a horizontal strip or multi-column gallery", () => {
const source = readToolbarSource();
const previewGridClassNames = [...source.matchAll(
/className="([^"]*\bmt-3\b[^"]*\bgrid\b[^"]*\bgap-2\b[^"]*)"/g,
test("attachment previews render in a stacked grid instead of a multi-column strip", async () => {
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 multiColumnPreviewPattern =
/(?:^|\s)(?:[a-z]+:)*(?:grid-cols-(?:\[[^\]]+\]|\S+)|grid-flow-col|auto-cols-(?:\[[^\]]+\]|\S+)|columns-(?:\[[^\]]+\]|\S+)|overflow-x-auto|flex-row)/;
expect(source).toContain('className="mt-3 grid gap-2"');
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);
});
@@ -255,6 +279,43 @@ describe("AI toolbar state rendering", () => {
expect(markup).toContain("Updated just now");
expect(markup).toContain("Dismiss AI summary");
});
test("Summarize only appears when there are events to summarize", async () => {
const emptyMarkup = await renderToolbarMarkup({ events: [] });
const populatedMarkup = await renderToolbarMarkup({
events: [{ id: "event-1" }],
});
expect(emptyMarkup).not.toContain("Summarize");
expect(populatedMarkup).toContain("Summarize");
});
test("Generate event stays disabled while loading or when both prompt and images are absent", async () => {
const emptyMarkup = await renderToolbarMarkup();
const loadingMarkup = await renderToolbarMarkup({
aiLoading: true,
aiPrompt: "Draft a kickoff",
});
const promptMarkup = await renderToolbarMarkup({
aiPrompt: "Draft a kickoff",
});
const imageMarkup = await renderToolbarMarkup({
imagePreviews: ["blob:first"],
});
expect(getButtonOpeningTag(emptyMarkup, "Generate event")).toContain(
" disabled=\"\"",
);
expect(getButtonOpeningTag(loadingMarkup, "Generating...")).toContain(
" disabled=\"\"",
);
expect(getButtonOpeningTag(promptMarkup, "Generate event")).not.toContain(
" disabled=",
);
expect(getButtonOpeningTag(imageMarkup, "Generate event")).not.toContain(
" disabled=",
);
});
});
describe("AI toolbar paste capture", () => {