Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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,60 @@
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", () => {
const { container } = render(
<ImageComponent
src="https://media.tenor.com/abc/tenor.gif"
altText={URL_PREVIEW_IMAGE_ALT_TEXT}
/>
);
const img = container.querySelector("img") as HTMLImageElement;
expect(img).toBeTruthy();
expect(img.getAttribute("alt")).toBe("");

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");
});
});
60 changes: 46 additions & 14 deletions __tests__/components/drops/create/lexical/nodes/ImageNode.test.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,61 @@
import { ImageNode, $createImageNode, $isImageNode } from '@/components/drops/create/lexical/nodes/ImageNode';
import {
ImageNode,
$createImageNode,
$isImageNode,
} from "@/components/drops/create/lexical/nodes/ImageNode";

jest.mock('lexical', () => {
jest.mock("lexical", () => {
class DecoratorNode {
constructor() {}
createDOM() { return document.createElement('span'); }
updateDOM() { return false; }
exportJSON() { return {}; }
createDOM() {
return document.createElement("span");
}
updateDOM() {
return false;
}
exportJSON() {
return {};
}
}
return { DecoratorNode, $applyNodeReplacement: (n: any) => n };
});

describe('ImageNode', () => {
it('exports and imports JSON', () => {
const node = $createImageNode({ src: 'img.png', altText: 'alt', width: 10, height: 20 });
describe("ImageNode", () => {
it("exports and imports JSON", () => {
const node = $createImageNode({
src: "img.png",
altText: "alt",
width: 10,
height: 20,
});
const json = node.exportJSON();
expect(json).toEqual({ type: 'image', version: 1, src: 'img.png', altText: 'alt', width: 10, height: 20 });
expect(json).toEqual({
type: "image",
version: 1,
src: "img.png",
altText: "alt",
width: 10,
height: 20,
});
const imported = ImageNode.importJSON(json);
expect(imported.getSrc()).toBe('img.png');
expect(imported.getSrc()).toBe("img.png");
expect($isImageNode(imported)).toBe(true);
});

it('createDOM adds class from theme', () => {
const node = $createImageNode({ src: 'a.png' });
const span = node.createDOM({ theme: { image: 'cls' } } as any);
expect(span.className).toBe('cls');
it("createDOM adds class from theme", () => {
const node = $createImageNode({ src: "a.png" });
const span = node.createDOM({ theme: { image: "cls" } } as any);
expect(span.className).toBe("cls");
expect(node.updateDOM(node, span, {} as any)).toBe(false);
});

it("uses loading fallback only for loading placeholder src", () => {
const loadingNode = $createImageNode({ src: "loading" });
const loadingDecorated = loadingNode.decorate() as any;
expect(loadingDecorated.props.fallback).toBeTruthy();

const regularNode = $createImageNode({ src: "a.png" });
const regularDecorated = regularNode.decorate() as any;
expect(regularDecorated.props.fallback).toBeNull();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
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 getNodeByKeyMock = 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(),
})),
$getNodeByKey: (key: string) => getNodeByKeyMock(key),
$getRoot: () => getRootMock(),
$getSelection: () => getSelectionMock(),
$insertNodes: (...args: unknown[]) => insertNodesMock(...args),
$isRangeSelection: (selection: unknown) => isRangeSelectionMock(selection),
$isTextNode: (node: unknown) => isTextNodeMock(node),
}));

let imageNodeCounter = 0;
const createImageNodeMock = jest.fn(
(opts: { src: string; altText: string }) => {
const key = `image-${++imageNodeCounter}`;
return {
__isImageNode: true,
...opts,
getSrc: () => opts.src,
getKey: () => key,
replace: jest.fn(),
insertAfter: jest.fn(),
selectNext: jest.fn(),
};
}
);

jest.mock("@/components/drops/create/lexical/nodes/ImageNode", () => ({
$isImageNode: (node: unknown) =>
Boolean(
(node as { readonly __isImageNode?: boolean } | null)?.__isImageNode
),
$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();
imageNodeCounter = 0;
});

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();
});

it("inserts loading placeholder for Tenor GIF and swaps to preview URL", () => {
const OriginalImage = global.Image;
class MockImage {
onload: null | (() => void) = null;
onerror: null | (() => void) = null;
set src(_value: string) {
this.onload?.();
}
}
(global as typeof globalThis & { Image: typeof Image }).Image =
MockImage as unknown as typeof Image;

try {
const selection = createSelection({ text: "hello", offset: 5 });
getSelectionMock.mockReturnValue(selection);

const placeholderNode = {
__isImageNode: true as const,
getSrc: () => "loading",
replace: jest.fn(),
};
getNodeByKeyMock.mockReturnValue(placeholderNode);

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

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

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

expect(createImageNodeMock).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ src: "loading" })
);
expect(getNodeByKeyMock).toHaveBeenCalled();
expect(placeholderNode.replace).toHaveBeenCalledWith(
expect.objectContaining({
src: "https://media.tenor.com/abc/tenor.gif",
})
);
} finally {
(global as typeof globalThis & { Image: typeof Image }).Image =
OriginalImage;
}
});
});
Loading