test(ai-toolbar): isolate split suites and action coverage

This commit is contained in:
2026-04-23 05:52:48 -04:00
parent bd08e9fc63
commit 2590e1dbaf
5 changed files with 143 additions and 10 deletions

View File

@@ -1,11 +1,11 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { import {
getMatchingClassTokens, getMatchingClassTokens,
registerAIToolbarTestHooks, registerAIToolbarMarkupHooks,
renderToolbarMarkup, renderToolbarMarkup,
} from "./helpers/ai-toolbar-test-helpers"; } from "./helpers/ai-toolbar-test-helpers";
registerAIToolbarTestHooks(); registerAIToolbarMarkupHooks();
describe("AI toolbar layout contracts", () => { describe("AI toolbar layout contracts", () => {
test("desktop composer uses a dedicated multi-column branch while mobile stays single-column", async () => { test("desktop composer uses a dedicated multi-column branch while mobile stays single-column", async () => {

View File

@@ -6,13 +6,13 @@ import {
createDocumentKeydownEvent, createDocumentKeydownEvent,
getDocumentListener, getDocumentListener,
getDocumentListenerMocks, getDocumentListenerMocks,
registerAIToolbarTestHooks, registerAIToolbarEffectHooks,
resetRegisteredDocumentListeners, resetRegisteredDocumentListeners,
renderToolbar, renderToolbar,
renderToolbarBoundary, renderToolbarBoundary,
} from "./helpers/ai-toolbar-test-helpers"; } from "./helpers/ai-toolbar-test-helpers";
registerAIToolbarTestHooks(); registerAIToolbarEffectHooks();
describe("AI toolbar paste capture", () => { describe("AI toolbar paste capture", () => {
test("textarea paste path forwards clipboard images to onImagesSelect through the component boundary", async () => { test("textarea paste path forwards clipboard images to onImagesSelect through the component boundary", async () => {

View File

@@ -1,11 +1,11 @@
import { describe, expect, mock, test } from "bun:test"; import { describe, expect, mock, test } from "bun:test";
import { import {
createTextareaKeydownEvent, createTextareaKeydownEvent,
registerAIToolbarTestHooks, registerAIToolbarMarkupHooks,
renderToolbarBoundary, renderToolbarBoundary,
} from "./helpers/ai-toolbar-test-helpers"; } from "./helpers/ai-toolbar-test-helpers";
registerAIToolbarTestHooks(); registerAIToolbarMarkupHooks();
describe("AI toolbar keyboard shortcuts", () => { describe("AI toolbar keyboard shortcuts", () => {
test("Ctrl+Enter triggers AI generation when the prompt has content", async () => { test("Ctrl+Enter triggers AI generation when the prompt has content", async () => {

View File

@@ -1,11 +1,12 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, mock, test } from "bun:test";
import { import {
getButtonOpeningTag, getButtonOpeningTag,
registerAIToolbarTestHooks, renderToolbarActionBoundary,
registerAIToolbarMarkupHooks,
renderToolbarMarkup, renderToolbarMarkup,
} from "./helpers/ai-toolbar-test-helpers"; } from "./helpers/ai-toolbar-test-helpers";
registerAIToolbarTestHooks(); registerAIToolbarMarkupHooks();
describe("AI toolbar state rendering", () => { describe("AI toolbar state rendering", () => {
test("unauthenticated users see the sign-in-required callout", async () => { 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"); 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 () => { test("Summarize only appears when there are events to summarize", async () => {
const emptyMarkup = await renderToolbarMarkup({ events: [] }); const emptyMarkup = await renderToolbarMarkup({ events: [] });
const populatedMarkup = await renderToolbarMarkup({ 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("Type or paste event details...");
expect(markup).not.toContain("Generate event"); 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);
});
}); });

View File

@@ -54,7 +54,7 @@ export const createToolbarProps = (
...overrides, ...overrides,
}); });
export const registerAIToolbarTestHooks = () => { export const registerAIToolbarEffectHooks = () => {
beforeEach(() => { beforeEach(() => {
documentAddEventListener.mockClear(); documentAddEventListener.mockClear();
documentRemoveEventListener.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 ( export const renderToolbar = async (
overrides: Partial<AIToolbarProps> = {}, overrides: Partial<AIToolbarProps> = {},
) => { ) => {
@@ -158,6 +178,52 @@ export const renderToolbarBoundary = async (
return { textareaProps: capturedTextareaProps!, imageTriggerOpen }; return { textareaProps: capturedTextareaProps!, imageTriggerOpen };
}; };
export const renderToolbarActionBoundary = async (
overrides: Partial<AIToolbarProps> = {},
) => {
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 = ( export const getDocumentListener = (
type: string, type: string,
predicate: (entry: { predicate: (entry: {