316 lines
8.5 KiB
TypeScript
316 lines
8.5 KiB
TypeScript
import { afterEach, beforeEach, expect, mock } from "bun:test";
|
|
import * as React from "react";
|
|
import { renderToStaticMarkup } from "react-dom/server";
|
|
import type { CalendarEvent } from "@/lib/types";
|
|
|
|
export type AIToolbarProps = {
|
|
adminAiEnabled: boolean;
|
|
aiEnabled: boolean;
|
|
isAuthenticated: boolean;
|
|
isPending: boolean;
|
|
aiPrompt: string;
|
|
setAiPrompt: (prompt: string) => void;
|
|
aiLoading: boolean;
|
|
imagePreviews: string[];
|
|
onImagesSelect: (files: File[]) => void;
|
|
onImageRemove: (index: number) => void;
|
|
onAiCreate: () => void;
|
|
onAiTemplateSelect: (prompt: string) => void;
|
|
onAiSummarize: () => void;
|
|
onSummaryDismiss: () => void;
|
|
summary: string | null;
|
|
summaryUpdated: string | null;
|
|
events: CalendarEvent[];
|
|
};
|
|
|
|
const actualReact = React;
|
|
const documentAddEventListener = mock();
|
|
const documentRemoveEventListener = mock();
|
|
let lastEffectCleanup: (() => void) | undefined;
|
|
const registeredDocumentListeners = new Map<
|
|
string,
|
|
Array<{ listener: EventListener; options?: AddEventListenerOptions }>
|
|
>();
|
|
|
|
export const createToolbarProps = (
|
|
overrides: Partial<AIToolbarProps> = {},
|
|
): AIToolbarProps => ({
|
|
adminAiEnabled: true,
|
|
aiEnabled: true,
|
|
isAuthenticated: true,
|
|
isPending: false,
|
|
aiPrompt: "",
|
|
setAiPrompt: () => {},
|
|
aiLoading: false,
|
|
imagePreviews: [],
|
|
onImagesSelect: () => {},
|
|
onImageRemove: () => {},
|
|
onAiCreate: () => {},
|
|
onAiTemplateSelect: () => {},
|
|
onAiSummarize: () => {},
|
|
onSummaryDismiss: () => {},
|
|
summary: null,
|
|
summaryUpdated: null,
|
|
events: [],
|
|
...overrides,
|
|
});
|
|
|
|
export const registerAIToolbarEffectHooks = () => {
|
|
beforeEach(() => {
|
|
documentAddEventListener.mockClear();
|
|
documentRemoveEventListener.mockClear();
|
|
lastEffectCleanup = undefined;
|
|
registeredDocumentListeners.clear();
|
|
globalThis.document = {
|
|
addEventListener: documentAddEventListener,
|
|
removeEventListener: documentRemoveEventListener,
|
|
} as unknown as Document;
|
|
documentAddEventListener.mockImplementation(
|
|
(
|
|
type: string,
|
|
listener: EventListener,
|
|
options?: AddEventListenerOptions,
|
|
) => {
|
|
const listeners = registeredDocumentListeners.get(type) ?? [];
|
|
listeners.push({ listener, options });
|
|
registeredDocumentListeners.set(type, listeners);
|
|
},
|
|
);
|
|
mock.module("react", () => ({
|
|
...actualReact,
|
|
useEffect: (effect: () => void | (() => void)) => {
|
|
const cleanup = effect();
|
|
lastEffectCleanup = typeof cleanup === "function" ? cleanup : undefined;
|
|
},
|
|
useRef: <T,>(initialValue: T) => ({ current: initialValue }),
|
|
useState: <T,>(initialValue: T) => [initialValue, mock()],
|
|
}));
|
|
mock.module("@/hooks/use-mobile", () => ({
|
|
useIsMobile: () => false,
|
|
}));
|
|
});
|
|
|
|
afterEach(() => {
|
|
delete (globalThis as { document?: Document }).document;
|
|
delete (globalThis as { navigator?: Navigator }).navigator;
|
|
lastEffectCleanup = undefined;
|
|
mock.restore();
|
|
});
|
|
};
|
|
|
|
export const registerAIToolbarMarkupHooks = () => {
|
|
beforeEach(() => {
|
|
globalThis.document = {
|
|
addEventListener: () => {},
|
|
removeEventListener: () => {},
|
|
} as unknown 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<AIToolbarProps> = {},
|
|
) => {
|
|
const { AIToolbar } = await import("@/components/ai-toolbar");
|
|
|
|
AIToolbar(createToolbarProps(overrides));
|
|
};
|
|
|
|
export 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(
|
|
actualReact.createElement(AIToolbar, createToolbarProps(overrides)),
|
|
);
|
|
};
|
|
|
|
export const renderToolbarBoundary = async (
|
|
overrides: Partial<AIToolbarProps> = {},
|
|
) => {
|
|
let capturedTextareaProps: React.ComponentProps<"textarea"> | undefined;
|
|
const imageTriggerOpen = mock();
|
|
|
|
mock.module("@/components/ui/textarea", () => ({
|
|
Textarea: (props: React.ComponentProps<"textarea">) => {
|
|
capturedTextareaProps = props;
|
|
return actualReact.createElement("textarea", props);
|
|
},
|
|
}));
|
|
mock.module("@/components/image-picker", () => ({
|
|
ImagePicker: ({
|
|
triggerRef,
|
|
children,
|
|
...props
|
|
}: React.ComponentProps<"button"> & {
|
|
triggerRef?: { current: { open: () => void } | null };
|
|
}) => {
|
|
if (triggerRef) {
|
|
triggerRef.current = { open: imageTriggerOpen };
|
|
}
|
|
return actualReact.createElement("button", props, children);
|
|
},
|
|
}));
|
|
|
|
const { AIToolbar } = await import("@/components/ai-toolbar");
|
|
|
|
renderToStaticMarkup(
|
|
actualReact.createElement(AIToolbar, createToolbarProps(overrides)),
|
|
);
|
|
|
|
expect(capturedTextareaProps).toBeDefined();
|
|
|
|
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 = (
|
|
type: string,
|
|
predicate: (entry: {
|
|
listener: EventListener;
|
|
options?: AddEventListenerOptions;
|
|
}) => boolean = () => true,
|
|
) => {
|
|
const entry = registeredDocumentListeners.get(type)?.find(predicate);
|
|
expect(entry).toBeDefined();
|
|
return entry!.listener;
|
|
};
|
|
|
|
export const getDocumentListenerMocks = () => ({
|
|
addEventListener: documentAddEventListener,
|
|
removeEventListener: documentRemoveEventListener,
|
|
lastEffectCleanup,
|
|
});
|
|
|
|
export const resetRegisteredDocumentListeners = () => {
|
|
registeredDocumentListeners.clear();
|
|
};
|
|
|
|
export const createClipboardData = (files: File[]): DataTransfer =>
|
|
({
|
|
files,
|
|
items: files.map((file) => ({
|
|
kind: "file",
|
|
type: file.type,
|
|
getAsFile: () => file,
|
|
})),
|
|
} as unknown as DataTransfer);
|
|
|
|
export const createTextareaKeydownEvent = (
|
|
overrides: Partial<React.KeyboardEvent<HTMLTextAreaElement>> = {},
|
|
) => ({
|
|
key: "Enter",
|
|
ctrlKey: false,
|
|
metaKey: false,
|
|
shiftKey: false,
|
|
altKey: false,
|
|
preventDefault: mock(),
|
|
...overrides,
|
|
}) as unknown as React.KeyboardEvent<HTMLTextAreaElement>;
|
|
|
|
export const createDocumentKeydownEvent = (
|
|
overrides: Partial<KeyboardEvent> = {},
|
|
) => ({
|
|
key: "v",
|
|
ctrlKey: false,
|
|
metaKey: false,
|
|
shiftKey: false,
|
|
altKey: false,
|
|
target: { tagName: "DIV", isContentEditable: false } as unknown as EventTarget,
|
|
...overrides,
|
|
}) as unknown as KeyboardEvent;
|
|
|
|
export const setMockNavigatorClipboardRead = (read: () => Promise<unknown>) => {
|
|
(globalThis as { navigator?: Navigator }).navigator = {
|
|
clipboard: { read },
|
|
} as unknown as Navigator;
|
|
};
|
|
|
|
const escapeForRegex = (value: string) =>
|
|
value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
|
|
export 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 getClassAttributes = (markup: string) =>
|
|
[...markup.matchAll(/class="([^"]+)"/g)].map(([, className]) => className);
|
|
|
|
export const getMatchingClassTokens = (
|
|
markup: string,
|
|
predicate: (tokens: string[]) => boolean,
|
|
) =>
|
|
getClassAttributes(markup)
|
|
.map((className) => className.split(/\s+/).filter(Boolean))
|
|
.filter(predicate);
|