diff --git a/tests/ai-toolbar.test.ts b/tests/ai-toolbar.test.ts index aa2e4f6..ba8fe03 100644 --- a/tests/ai-toolbar.test.ts +++ b/tests/ai-toolbar.test.ts @@ -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 = {}) => { const renderToolbarMarkup = async ( overrides: Partial = {}, + { 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>)[\\s\\S])*?${escapeForRegex(label)}(?:(?!<\\/button>)[\\s\\S])*?<\\/button>`, + ), + ); + const openingTagMatch = buttonMatch?.[0].match(/^]*>/); + + expect(openingTagMatch).toBeDefined(); + + return openingTagMatch![0]; +}; + const renderToolbarBoundary = async ( overrides: Partial = {}, ) => { @@ -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", () => {