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 { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import { readFileSync } from "node:fs";
import * as React from "react"; import * as React from "react";
import { renderToStaticMarkup } from "react-dom/server"; import { renderToStaticMarkup } from "react-dom/server";
const readToolbarSource = () => readFileSync("src/components/ai-toolbar.tsx", "utf8");
type AIToolbarProps = { type AIToolbarProps = {
adminAiEnabled: boolean; adminAiEnabled: boolean;
aiEnabled: boolean; aiEnabled: boolean;
@@ -128,7 +125,12 @@ const renderToolbar = async (overrides: Partial<AIToolbarProps> = {}) => {
const renderToolbarMarkup = async ( const renderToolbarMarkup = async (
overrides: Partial<AIToolbarProps> = {}, overrides: Partial<AIToolbarProps> = {},
{ isMobile = false }: { isMobile?: boolean } = {},
) => { ) => {
mock.module("@/hooks/use-mobile", () => ({
useIsMobile: () => isMobile,
}));
const { AIToolbar } = await import("@/components/ai-toolbar"); const { AIToolbar } = await import("@/components/ai-toolbar");
return renderToStaticMarkup( 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 ( const renderToolbarBoundary = async (
overrides: Partial<AIToolbarProps> = {}, overrides: Partial<AIToolbarProps> = {},
) => { ) => {
@@ -160,27 +178,29 @@ const renderToolbarBoundary = async (
}; };
describe("AI toolbar layout contracts", () => { describe("AI toolbar layout contracts", () => {
test("desktop composer uses a 70/30 split driven by the isMobile branch", () => { test("desktop composer renders a 70/30 split while mobile stays single-column", async () => {
const source = readToolbarSource(); const desktopMarkup = await renderToolbarMarkup();
const mobileMarkup = await renderToolbarMarkup({}, { isMobile: true });
expect(source).toContain("isMobile"); expect(desktopMarkup).toContain(
expect(source).toContain('? "grid gap-3"'); "grid gap-3 grid-cols-[minmax(0,0.7fr)_minmax(0,0.3fr)]",
expect(source).toContain(
': "grid gap-3 grid-cols-[minmax(0,0.7fr)_minmax(0,0.3fr)]"',
); );
expect(source).not.toContain( expect(desktopMarkup).not.toContain(
': "grid gap-3 grid-cols-[minmax(0,1fr)_minmax(0,1fr)]"', "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", () => { test("example prompts render as a masonry-style cluster below the textarea", async () => {
const source = readToolbarSource(); const markup = await renderToolbarMarkup();
expect(source).toContain("Try:"); expect(markup).toContain("Try:");
expect(source).toContain('className="columns-2 gap-2"'); expect(markup).toContain('class="columns-2 gap-2"');
expect(source).toContain('className="mb-2 break-inside-avoid"'); expect(markup).toContain('class="mb-2 break-inside-avoid"');
expect(source).toContain( expect(markup).toContain(
'className="h-auto w-full justify-start text-left whitespace-normal rounded-2xl px-3 py-2 text-[11px] leading-relaxed"', "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", () => { test("attachment previews render in a stacked grid instead of a multi-column strip", async () => {
const source = readToolbarSource(); const markup = await renderToolbarMarkup({
const previewGridClassNames = [...source.matchAll( imagePreviews: ["blob:first", "blob:second"],
/className="([^"]*\bmt-3\b[^"]*\bgrid\b[^"]*\bgap-2\b[^"]*)"/g, });
const previewGridClassNames = [...markup.matchAll(
/class="([^"]*\bmt-3\b[^"]*\bgrid\b[^"]*\bgap-2\b[^"]*)"/g,
)].map(([, className]) => className); )].map(([, className]) => className);
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(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).toHaveLength(1);
expect(previewGridClassNames[0]).not.toMatch(multiColumnPreviewPattern); expect(previewGridClassNames[0]).not.toMatch(multiColumnPreviewPattern);
}); });
@@ -255,6 +279,43 @@ describe("AI toolbar state rendering", () => {
expect(markup).toContain("Updated just now"); expect(markup).toContain("Updated just now");
expect(markup).toContain("Dismiss AI summary"); 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", () => { describe("AI toolbar paste capture", () => {