Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,39 +1,58 @@
import { render, screen, fireEvent } from '@testing-library/react';
import ImageComponent from '@/components/drops/create/lexical/nodes/ImageComponent';
import { render, screen, fireEvent } from "@testing-library/react";
import ImageComponent from "@/components/drops/create/lexical/nodes/ImageComponent";
import { URL_PREVIEW_IMAGE_ALT_TEXT } from "@/components/drops/create/lexical/nodes/urlPreviewImage.constants";
import { CHAT_GIF_PREVIEW_HEIGHT_PX } from "@/components/waves/drops/gifPreview";

jest.mock('@/components/distribution-plan-tool/common/CircleLoader', () => ({
jest.mock("@/components/distribution-plan-tool/common/CircleLoader", () => ({
__esModule: true,
default: jest.fn(() => <div data-testid="loader" />),
CircleLoaderSize: { MEDIUM: 'MEDIUM' }
CircleLoaderSize: { MEDIUM: "MEDIUM" },
}));

describe('ImageComponent', () => {
describe("ImageComponent", () => {
it('renders loader when src is "loading"', () => {
render(<ImageComponent src="loading" />);
expect(screen.getByTestId('loader')).toBeInTheDocument();
expect(screen.getByTestId("loader")).toBeInTheDocument();
});

it('calculates dimensions when width and height are not provided', () => {
it("calculates dimensions when width and height are not provided", () => {
render(<ImageComponent src="img.png" altText="img" />);
const img = screen.getByRole('img', { name: 'img' }) as HTMLImageElement;
const img = screen.getByRole("img", { name: "img" }) as HTMLImageElement;

Object.defineProperty(img, 'naturalWidth', { value: 1200 });
Object.defineProperty(img, 'naturalHeight', { value: 600 });
Object.defineProperty(img, "naturalWidth", { value: 1200 });
Object.defineProperty(img, "naturalHeight", { value: 600 });
fireEvent.load(img);

expect(img).toHaveAttribute('width', '800');
expect(img).toHaveAttribute('height', '400');
expect(img).toHaveAttribute("width", "800");
expect(img).toHaveAttribute("height", "400");
});

it('respects provided width and computes height', () => {
it("respects provided width and computes height", () => {
render(<ImageComponent src="foo.jpg" altText="foo" width={200} />);
const img = screen.getByRole('img', { name: 'foo' }) as HTMLImageElement;
const img = screen.getByRole("img", { name: "foo" }) as HTMLImageElement;

Object.defineProperty(img, 'naturalWidth', { value: 600 });
Object.defineProperty(img, 'naturalHeight', { value: 300 });
Object.defineProperty(img, "naturalWidth", { value: 600 });
Object.defineProperty(img, "naturalHeight", { value: 300 });
fireEvent.load(img);

expect(img).toHaveAttribute('width', '200');
expect(img).toHaveAttribute('height', '100');
expect(img).toHaveAttribute("width", "200");
expect(img).toHaveAttribute("height", "100");
});

it("uses markdown-matching fixed height for URL-preview Tenor GIFs", () => {
render(
<ImageComponent
src="https://media.tenor.com/abc/tenor.gif"
altText={URL_PREVIEW_IMAGE_ALT_TEXT}
/>
);
const img = screen.getByRole("img") as HTMLImageElement;

Object.defineProperty(img, "naturalWidth", { value: 1200 });
Object.defineProperty(img, "naturalHeight", { value: 600 });
fireEvent.load(img);

expect(img).toHaveStyle(`height: ${CHAT_GIF_PREVIEW_HEIGHT_PX}px`);
expect(img).toHaveStyle("width: auto");
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { render } from "@testing-library/react";
import React, { createRef } from "react";
import InsertTextPlugin, {
type InsertTextPluginHandles,
} from "@/components/drops/create/lexical/plugins/InsertTextPlugin";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";

jest.mock("@lexical/react/LexicalComposerContext");

const selectEndMock = jest.fn();
const getRootMock = jest.fn(() => ({
selectEnd: selectEndMock,
}));
const getSelectionMock = jest.fn();
const insertNodesMock = jest.fn();
const isRangeSelectionMock = jest.fn((selection: unknown) =>
Boolean(
(selection as { readonly __isRangeSelection?: boolean } | null)
?.__isRangeSelection
)
);
const isTextNodeMock = jest.fn((node: unknown) =>
Boolean((node as { readonly __isTextNode?: boolean } | null)?.__isTextNode)
);

jest.mock("lexical", () => ({
$createTextNode: jest.fn((text: string) => ({
__isTextNode: true,
text,
insertAfter: jest.fn(),
selectEnd: jest.fn(),
})),
$getRoot: () => getRootMock(),
$getSelection: () => getSelectionMock(),
$insertNodes: (...args: unknown[]) => insertNodesMock(...args),
$isRangeSelection: (selection: unknown) => isRangeSelectionMock(selection),
$isTextNode: (node: unknown) => isTextNodeMock(node),
}));

const createImageNodeMock = jest.fn(
(opts: { src: string; altText: string }) => ({
...opts,
insertAfter: jest.fn(),
selectNext: jest.fn(),
})
);

jest.mock("@/components/drops/create/lexical/nodes/ImageNode", () => ({
$createImageNode: (opts: { src: string; altText: string }) =>
createImageNodeMock(opts),
}));

const useCtx = useLexicalComposerContext as jest.Mock;

type MockSelection = {
readonly __isRangeSelection: true;
readonly anchor: {
readonly getNode: () => {
readonly __isTextNode: true;
getTextContent: () => string;
};
readonly offset: number;
};
readonly isCollapsed: () => boolean;
readonly insertRawText: jest.Mock;
};

const createSelection = ({
text,
offset,
collapsed = true,
}: {
readonly text: string;
readonly offset: number;
readonly collapsed?: boolean;
}): MockSelection => {
const node = {
__isTextNode: true as const,
getTextContent: () => text,
};

return {
__isRangeSelection: true as const,
anchor: {
getNode: () => node,
offset,
},
isCollapsed: () => collapsed,
insertRawText: jest.fn(),
};
};

describe("InsertTextPlugin", () => {
beforeEach(() => {
jest.clearAllMocks();
});

it("inserts text with smart spacing when needed", () => {
const selection = createSelection({ text: "helloworld", offset: 5 });
getSelectionMock.mockReturnValue(selection);

const update = jest.fn((fn: () => void) => fn());
useCtx.mockReturnValue([{ update }]);

const ref = createRef<InsertTextPluginHandles>();
render(<InsertTextPlugin ref={ref} />);

ref.current?.insertTextAtCursor("https://media.tenor.com/abc/tenor.gif", {
smartSpacing: true,
});

expect(selection.insertRawText).toHaveBeenCalledWith(
" https://media.tenor.com/abc/tenor.gif "
);
});

it("falls back to end selection when no cursor selection exists", () => {
const fallbackSelection = createSelection({ text: "hello", offset: 5 });
getSelectionMock
.mockReturnValueOnce(null)
.mockReturnValueOnce(fallbackSelection);

selectEndMock.mockImplementation(() => {
return undefined;
});

const update = jest.fn((fn: () => void) => fn());
useCtx.mockReturnValue([{ update }]);

const ref = createRef<InsertTextPluginHandles>();
render(<InsertTextPlugin ref={ref} />);

ref.current?.insertTextAtCursor("https://media.tenor.com/abc/tenor.gif");

expect(selectEndMock).toHaveBeenCalled();
expect(fallbackSelection.insertRawText).toHaveBeenCalledWith(
"https://media.tenor.com/abc/tenor.gif"
);
});

it("inserts preview image node for URL insertion", () => {
const selection = createSelection({ text: "hello", offset: 5 });
getSelectionMock.mockReturnValue(selection);

const update = jest.fn((fn: () => void) => fn());
useCtx.mockReturnValue([{ update }]);

const ref = createRef<InsertTextPluginHandles>();
render(<InsertTextPlugin ref={ref} />);

ref.current?.insertImagePreviewFromUrl("https://example.com/cat.gif", {
smartSpacing: true,
});

expect(createImageNodeMock).toHaveBeenCalledWith(
expect.objectContaining({ src: "https://example.com/cat.gif" })
);
expect(insertNodesMock).toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { render } from "@testing-library/react";
import StandaloneImageUrlPreviewPlugin from "@/components/drops/create/lexical/plugins/StandaloneImageUrlPreviewPlugin";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import {
COMMAND_PRIORITY_CRITICAL,
KEY_SPACE_COMMAND,
PASTE_COMMAND,
} from "lexical";
import { parseStandaloneMediaUrl } from "@/components/waves/drops/media-utils";

jest.mock("@lexical/react/LexicalComposerContext");

const getSelectionMock = jest.fn();
const isRangeSelectionMock = jest.fn((selection: unknown) =>
Boolean(selection)
);
const isTextNodeMock = jest.fn((node: unknown) =>
Boolean((node as { readonly __isTextNode?: boolean } | null)?.__isTextNode)
);
const createTextNodeMock = jest.fn((text: string) => ({
__isTextNode: true,
text,
insertAfter: jest.fn(),
selectEnd: jest.fn(),
selectStart: jest.fn(),
}));
const getRootMock = jest.fn(() => ({ selectEnd: jest.fn() }));

jest.mock("lexical", () => ({
$createTextNode: (text: string) => createTextNodeMock(text),
$getRoot: () => getRootMock(),
$getSelection: () => getSelectionMock(),
$isRangeSelection: (selection: unknown) => isRangeSelectionMock(selection),
$isTextNode: (node: unknown) => isTextNodeMock(node),
COMMAND_PRIORITY_CRITICAL: 4,
COMMAND_PRIORITY_NORMAL: 2,
KEY_ENTER_COMMAND: "KEY_ENTER_COMMAND",
KEY_SPACE_COMMAND: "KEY_SPACE_COMMAND",
PASTE_COMMAND: "PASTE_COMMAND",
}));

const createImageNodeMock = jest.fn(
(opts: { src: string; altText: string }) => ({
...opts,
insertAfter: jest.fn(),
selectNext: jest.fn(),
})
);

jest.mock("@/components/drops/create/lexical/nodes/ImageNode", () => ({
$createImageNode: (opts: { src: string; altText: string }) =>
createImageNodeMock(opts),
}));

jest.mock("@/components/waves/drops/media-utils", () => ({
parseStandaloneMediaUrl: jest.fn(),
}));

const useLexicalComposerContextMock = useLexicalComposerContext as jest.Mock;
const parseStandaloneMediaUrlMock = parseStandaloneMediaUrl as jest.Mock;

describe("StandaloneImageUrlPreviewPlugin", () => {
beforeEach(() => {
jest.clearAllMocks();
parseStandaloneMediaUrlMock.mockImplementation((token: string) =>
token.endsWith(".png") || token.endsWith(".gif")
? { type: "image", url: token, alt: "Media" }
: null
);
});

it("converts standalone image URL token on space key", () => {
const commandHandlers = new Map<string, (event?: any) => boolean>();
const editor = {
registerCommand: jest.fn(
(
command: string,
handler: (event?: any) => boolean,
priority: number
) => {
if (priority === COMMAND_PRIORITY_CRITICAL) {
commandHandlers.set(command, handler);
}
return () => {};
}
),
update: (fn: () => void) => fn(),
};
useLexicalComposerContextMock.mockReturnValue([editor]);

const urlTextNode = {
__isTextNode: true as const,
getTextContent: () => "https://example.com/cat.gif",
replace: jest.fn(),
};
getSelectionMock.mockReturnValue({
isCollapsed: () => true,
anchor: {
getNode: () => urlTextNode,
offset: "https://example.com/cat.gif".length,
},
});

render(<StandaloneImageUrlPreviewPlugin />);

const preventDefault = jest.fn();
const handled = commandHandlers.get(KEY_SPACE_COMMAND)?.({
preventDefault,
});

expect(handled).toBe(true);
expect(preventDefault).toHaveBeenCalled();
expect(urlTextNode.replace).toHaveBeenCalled();
expect(createImageNodeMock).toHaveBeenCalledWith(
expect.objectContaining({ src: "https://example.com/cat.gif" })
);
});

it("converts pasted standalone image URLs into preview image node", () => {
const commandHandlers = new Map<string, (event?: any) => boolean>();
const editor = {
registerCommand: jest.fn(
(command: string, handler: (event?: any) => boolean) => {
commandHandlers.set(command, handler);
return () => {};
}
),
update: (fn: () => void) => fn(),
};
useLexicalComposerContextMock.mockReturnValue([editor]);

const insertNodes = jest.fn();
getSelectionMock.mockReturnValue({
isCollapsed: () => true,
insertNodes,
anchor: { getNode: () => null, offset: 0 },
});

render(<StandaloneImageUrlPreviewPlugin />);

const preventDefault = jest.fn();
const handled = commandHandlers.get(PASTE_COMMAND)?.({
preventDefault,
clipboardData: {
files: [],
getData: () => "https://example.com/banner.png",
},
});

expect(handled).toBe(true);
expect(preventDefault).toHaveBeenCalled();
expect(insertNodes).toHaveBeenCalledWith([
expect.objectContaining({ src: "https://example.com/banner.png" }),
]);
});
});
Loading