From 2590e1dbaff908f2b961bc8de83a0af8899d81dd Mon Sep 17 00:00:00 2001 From: Dmytro Stanchiev Date: Thu, 23 Apr 2026 05:52:48 -0400 Subject: [PATCH] test(ai-toolbar): isolate split suites and action coverage --- tests/ai-toolbar-layout.test.ts | 4 +- tests/ai-toolbar-paste.test.ts | 4 +- tests/ai-toolbar-shortcuts.test.ts | 4 +- tests/ai-toolbar-state.test.ts | 73 +++++++++++++++++++++++- tests/helpers/ai-toolbar-test-helpers.ts | 68 +++++++++++++++++++++- 5 files changed, 143 insertions(+), 10 deletions(-) diff --git a/tests/ai-toolbar-layout.test.ts b/tests/ai-toolbar-layout.test.ts index e24c4e7..8907b55 100644 --- a/tests/ai-toolbar-layout.test.ts +++ b/tests/ai-toolbar-layout.test.ts @@ -1,11 +1,11 @@ import { describe, expect, test } from "bun:test"; import { getMatchingClassTokens, - registerAIToolbarTestHooks, + registerAIToolbarMarkupHooks, renderToolbarMarkup, } from "./helpers/ai-toolbar-test-helpers"; -registerAIToolbarTestHooks(); +registerAIToolbarMarkupHooks(); describe("AI toolbar layout contracts", () => { test("desktop composer uses a dedicated multi-column branch while mobile stays single-column", async () => { diff --git a/tests/ai-toolbar-paste.test.ts b/tests/ai-toolbar-paste.test.ts index 7ec6834..de143fd 100644 --- a/tests/ai-toolbar-paste.test.ts +++ b/tests/ai-toolbar-paste.test.ts @@ -6,13 +6,13 @@ import { createDocumentKeydownEvent, getDocumentListener, getDocumentListenerMocks, - registerAIToolbarTestHooks, + registerAIToolbarEffectHooks, resetRegisteredDocumentListeners, renderToolbar, renderToolbarBoundary, } from "./helpers/ai-toolbar-test-helpers"; -registerAIToolbarTestHooks(); +registerAIToolbarEffectHooks(); describe("AI toolbar paste capture", () => { test("textarea paste path forwards clipboard images to onImagesSelect through the component boundary", async () => { diff --git a/tests/ai-toolbar-shortcuts.test.ts b/tests/ai-toolbar-shortcuts.test.ts index bb01131..2384bba 100644 --- a/tests/ai-toolbar-shortcuts.test.ts +++ b/tests/ai-toolbar-shortcuts.test.ts @@ -1,11 +1,11 @@ import { describe, expect, mock, test } from "bun:test"; import { createTextareaKeydownEvent, - registerAIToolbarTestHooks, + registerAIToolbarMarkupHooks, renderToolbarBoundary, } from "./helpers/ai-toolbar-test-helpers"; -registerAIToolbarTestHooks(); +registerAIToolbarMarkupHooks(); describe("AI toolbar keyboard shortcuts", () => { test("Ctrl+Enter triggers AI generation when the prompt has content", async () => { diff --git a/tests/ai-toolbar-state.test.ts b/tests/ai-toolbar-state.test.ts index 32a62b2..2447d03 100644 --- a/tests/ai-toolbar-state.test.ts +++ b/tests/ai-toolbar-state.test.ts @@ -1,11 +1,12 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, mock, test } from "bun:test"; import { getButtonOpeningTag, - registerAIToolbarTestHooks, + renderToolbarActionBoundary, + registerAIToolbarMarkupHooks, renderToolbarMarkup, } from "./helpers/ai-toolbar-test-helpers"; -registerAIToolbarTestHooks(); +registerAIToolbarMarkupHooks(); describe("AI toolbar state rendering", () => { test("unauthenticated users see the sign-in-required callout", async () => { @@ -54,6 +55,14 @@ describe("AI toolbar state rendering", () => { expect(markup).toContain("Dismiss AI summary"); }); + test("pending state renders loading skeletons instead of the live composer", async () => { + const markup = await renderToolbarMarkup({ isPending: true }); + + expect(markup).toContain("h-[160px]"); + expect(markup).toContain("h-12"); + expect(markup).not.toContain("Type or paste event details..."); + }); + test("Summarize only appears when there are events to summarize", async () => { const emptyMarkup = await renderToolbarMarkup({ events: [] }); const populatedMarkup = await renderToolbarMarkup({ @@ -98,4 +107,62 @@ describe("AI toolbar state rendering", () => { expect(markup).not.toContain("Type or paste event details..."); expect(markup).not.toContain("Generate event"); }); + + test("example prompt buttons call onAiTemplateSelect with the selected prompt", async () => { + const onAiTemplateSelectMock = mock(); + const actions = await renderToolbarActionBoundary({ + onAiTemplateSelect: onAiTemplateSelectMock, + }); + const promptButton = actions.getButtonByLabel("Lunch with Maya next Thursday"); + + promptButton.onClick?.({} as never); + + expect(onAiTemplateSelectMock).toHaveBeenCalledTimes(1); + expect(onAiTemplateSelectMock).toHaveBeenCalledWith( + "Lunch with Maya next Thursday at 12:30pm at Toma, remind me 30 minutes before.", + ); + }); + + test("Summarize button calls onAiSummarize when events are present", async () => { + const onAiSummarizeMock = mock(); + const actions = await renderToolbarActionBoundary({ + events: [{ id: "event-1" }], + onAiSummarize: onAiSummarizeMock, + }); + + actions + .getButtonByLabel("Summarize") + .onClick?.({} as never); + + expect(onAiSummarizeMock).toHaveBeenCalledTimes(1); + }); + + test("summary dismiss button calls onSummaryDismiss", async () => { + const onSummaryDismissMock = mock(); + const actions = await renderToolbarActionBoundary({ + summary: "Three events need attention.", + onSummaryDismiss: onSummaryDismissMock, + }); + + actions + .getButtonByAriaLabel("Dismiss AI summary") + .onClick?.({} as never); + + expect(onSummaryDismissMock).toHaveBeenCalledTimes(1); + }); + + test("remove image button calls onImageRemove with the preview index", async () => { + const onImageRemoveMock = mock(); + const actions = await renderToolbarActionBoundary({ + imagePreviews: ["blob:first", "blob:second"], + onImageRemove: onImageRemoveMock, + }); + + actions + .getButtonByAriaLabel("Remove image 2") + .onClick?.({} as never); + + expect(onImageRemoveMock).toHaveBeenCalledTimes(1); + expect(onImageRemoveMock).toHaveBeenCalledWith(1); + }); }); diff --git a/tests/helpers/ai-toolbar-test-helpers.ts b/tests/helpers/ai-toolbar-test-helpers.ts index fa68be2..bbdec67 100644 --- a/tests/helpers/ai-toolbar-test-helpers.ts +++ b/tests/helpers/ai-toolbar-test-helpers.ts @@ -54,7 +54,7 @@ export const createToolbarProps = ( ...overrides, }); -export const registerAIToolbarTestHooks = () => { +export const registerAIToolbarEffectHooks = () => { beforeEach(() => { documentAddEventListener.mockClear(); documentRemoveEventListener.mockClear(); @@ -97,6 +97,26 @@ export const registerAIToolbarTestHooks = () => { }); }; +export const registerAIToolbarMarkupHooks = () => { + beforeEach(() => { + globalThis.document = { + addEventListener: () => {}, + removeEventListener: () => {}, + } as Document; + mock.module("@/hooks/use-mobile", () => ({ + useIsMobile: () => false, + })); + }); + + afterEach(() => { + delete (globalThis as { document?: Document }).document; + delete (globalThis as { navigator?: Navigator }).navigator; + mock.restore(); + }); +}; + +export const registerAIToolbarTestHooks = registerAIToolbarEffectHooks; + export const renderToolbar = async ( overrides: Partial = {}, ) => { @@ -158,6 +178,52 @@ export const renderToolbarBoundary = async ( return { textareaProps: capturedTextareaProps!, imageTriggerOpen }; }; +export const renderToolbarActionBoundary = async ( + overrides: Partial = {}, +) => { + type CapturedButton = React.ComponentProps<"button"> & { + label: string; + ariaLabel?: string; + }; + const buttons: CapturedButton[] = []; + + mock.module("@/components/ui/button", () => ({ + Button: ({ children, ...props }: React.ComponentProps<"button">) => { + const label = renderToStaticMarkup( + actualReact.createElement(actualReact.Fragment, null, children), + ) + .replace(/<[^>]+>/g, "") + .trim(); + buttons.push({ + ...props, + label, + ariaLabel: props["aria-label"] as string | undefined, + }); + return actualReact.createElement("button", props, children); + }, + })); + + const { AIToolbar } = await import("@/components/ai-toolbar"); + + renderToStaticMarkup( + actualReact.createElement(AIToolbar, createToolbarProps(overrides)), + ); + + return { + buttons, + getButtonByLabel: (label: string) => { + const button = buttons.find((entry) => entry.label.includes(label)); + expect(button).toBeDefined(); + return button!; + }, + getButtonByAriaLabel: (ariaLabel: string) => { + const button = buttons.find((entry) => entry.ariaLabel === ariaLabel); + expect(button).toBeDefined(); + return button!; + }, + }; +}; + export const getDocumentListener = ( type: string, predicate: (entry: {