@gravity-ui/graph
Version:
Modern graph editor component
303 lines (302 loc) • 14.8 kB
JavaScript
import { Graph } from "../../graph";
import { ReactLayer } from "./ReactLayer";
describe("ReactLayer", () => {
let graph;
let camera;
let rootElement;
// Constants for default classes
const DEFAULT_LAYER_CLASSES = ["layer", "layer-html", "no-user-select", "layer-with-camera"];
beforeEach(() => {
graph = new Graph({});
camera = graph.cameraService;
rootElement = document.createElement("div");
document.body.appendChild(rootElement);
});
afterEach(() => {
document.body.removeChild(rootElement);
});
// Helper function to create ReactLayer with default props
const createLayer = (blockListClassName) => {
return new ReactLayer({
graph,
camera,
root: rootElement,
blockListClassName,
});
};
// Helper function to create ReactLayer without root (unattached)
const createUnattachedLayer = (blockListClassName) => {
return new ReactLayer({
graph,
camera,
root: undefined,
blockListClassName,
});
};
// Helper function to get HTML element safely
const getHTMLElement = (layer) => {
layer.attachLayer(rootElement);
const htmlElement = layer.getHTML();
expect(htmlElement).toBeTruthy();
return htmlElement;
};
// Helper function to check if element has only default classes
const hasOnlyDefaultClasses = (element) => {
return (DEFAULT_LAYER_CLASSES.every((cls) => element.classList.contains(cls)) &&
element.classList.length === DEFAULT_LAYER_CLASSES.length);
};
describe("initialization", () => {
it("should create layer with default HTML classes", () => {
const layer = createLayer();
const htmlElement = getHTMLElement(layer);
DEFAULT_LAYER_CLASSES.forEach((className) => {
expect(htmlElement.classList.contains(className)).toBe(true);
});
});
it("should handle layer creation without attachment", () => {
const layer = createUnattachedLayer("test-class");
const htmlElement = layer.getHTML();
expect(htmlElement).toBeTruthy();
expect(htmlElement.parentNode).toBeNull(); // Not attached to DOM
// blockListClassName is not applied until attachLayer is called (which calls afterInit)
expect(htmlElement.classList.contains("test-class")).toBe(false);
expect(hasOnlyDefaultClasses(htmlElement)).toBe(true);
});
it("should initialize with correct z-index", () => {
const layer = createLayer();
const htmlElement = getHTMLElement(layer);
expect(htmlElement.style.zIndex).toBe("3");
});
});
describe("blockListClassName", () => {
it("should apply blockListClassName to HTML element", () => {
const className = "test-class";
const layer = createLayer(className);
const htmlElement = getHTMLElement(layer);
expect(htmlElement.classList.contains(className)).toBe(true);
});
it("should update blockListClassName when props change", () => {
const initialClassName = "initial-class";
const newClassName = "new-class";
const layer = createLayer(initialClassName);
const htmlElement = getHTMLElement(layer);
// Check initial class
expect(htmlElement.classList.contains(initialClassName)).toBe(true);
// Update props and force iterate to trigger propsChanged
layer.setProps({ blockListClassName: newClassName });
layer.iterate();
// Check that old class is removed and new class is added
expect(htmlElement.classList.contains(initialClassName)).toBe(false);
expect(htmlElement.classList.contains(newClassName)).toBe(true);
});
it("should handle undefined blockListClassName", () => {
const layer = createLayer(undefined);
const htmlElement = getHTMLElement(layer);
expect(hasOnlyDefaultClasses(htmlElement)).toBe(true);
});
it("should remove class when changed to undefined", () => {
const className = "test-class";
const layer = createLayer(className);
const htmlElement = getHTMLElement(layer);
// Check initial class
expect(htmlElement.classList.contains(className)).toBe(true);
// Update to undefined and force iterate to trigger propsChanged
layer.setProps({ blockListClassName: undefined });
layer.iterate();
// Check that class is removed
expect(htmlElement.classList.contains(className)).toBe(false);
});
it("should not affect other default classes", () => {
const className = "test-class";
const layer = createLayer(className);
const htmlElement = getHTMLElement(layer);
// Check that default classes are preserved
DEFAULT_LAYER_CLASSES.forEach((defaultClass) => {
expect(htmlElement.classList.contains(defaultClass)).toBe(true);
});
expect(htmlElement.classList.contains(className)).toBe(true);
});
it("should handle multiple classes in a string", () => {
const multipleClasses = "class1 class2 class3";
const layer = createLayer(multipleClasses);
const htmlElement = getHTMLElement(layer);
// Check that all classes are applied
expect(htmlElement.classList.contains("class1")).toBe(true);
expect(htmlElement.classList.contains("class2")).toBe(true);
expect(htmlElement.classList.contains("class3")).toBe(true);
});
it("should update multiple classes correctly", () => {
const initialClasses = "initial1 initial2";
const newClasses = "new1 new2 new3";
const layer = createLayer(initialClasses);
const htmlElement = getHTMLElement(layer);
// Check initial classes
expect(htmlElement.classList.contains("initial1")).toBe(true);
expect(htmlElement.classList.contains("initial2")).toBe(true);
// Update props and force iterate
layer.setProps({ blockListClassName: newClasses });
layer.iterate();
// Check that old classes are removed and new classes are added
expect(htmlElement.classList.contains("initial1")).toBe(false);
expect(htmlElement.classList.contains("initial2")).toBe(false);
expect(htmlElement.classList.contains("new1")).toBe(true);
expect(htmlElement.classList.contains("new2")).toBe(true);
expect(htmlElement.classList.contains("new3")).toBe(true);
});
it("should handle empty and whitespace strings", () => {
const emptyClasses = " ";
const layer = createLayer(emptyClasses);
const htmlElement = getHTMLElement(layer);
// Should only have default classes
expect(hasOnlyDefaultClasses(htmlElement)).toBe(true);
});
it("should remove multiple classes when changed to undefined", () => {
const multipleClasses = "class1 class2 class3";
const layer = createLayer(multipleClasses);
const htmlElement = getHTMLElement(layer);
// Check initial classes
expect(htmlElement.classList.contains("class1")).toBe(true);
expect(htmlElement.classList.contains("class2")).toBe(true);
expect(htmlElement.classList.contains("class3")).toBe(true);
// Update to undefined
layer.setProps({ blockListClassName: undefined });
layer.iterate();
// Check that all classes are removed
expect(htmlElement.classList.contains("class1")).toBe(false);
expect(htmlElement.classList.contains("class2")).toBe(false);
expect(htmlElement.classList.contains("class3")).toBe(false);
});
it("should not cause unnecessary DOM operations when setting same class", () => {
const className = "test-class";
const layer = createLayer(className);
const htmlElement = getHTMLElement(layer);
const addSpy = jest.spyOn(htmlElement.classList, "add");
const removeSpy = jest.spyOn(htmlElement.classList, "remove");
// Set same class
layer.setProps({ blockListClassName: className });
layer.iterate();
// Should not add or remove classes
expect(addSpy).not.toHaveBeenCalled();
expect(removeSpy).not.toHaveBeenCalled();
addSpy.mockRestore();
removeSpy.mockRestore();
});
});
describe("renderPortal", () => {
it("should return null when HTML element is forcibly removed", () => {
const layer = createUnattachedLayer();
const renderBlock = jest.fn();
// Mock getHTML to return null
jest.spyOn(layer, "getHTML").mockReturnValue(null);
const portal = layer.renderPortal(renderBlock);
expect(portal).toBeNull();
});
it("should create portal even when layer is not attached", () => {
const layer = createUnattachedLayer();
const renderBlock = jest.fn();
const portal = layer.renderPortal(renderBlock);
expect(portal).toBeTruthy();
expect(portal).toHaveProperty("$$typeof"); // React Portal symbol
expect(portal).toHaveProperty("key", "graph-blocks-list");
});
it("should create portal when HTML element is available", () => {
const layer = createLayer();
const _htmlElement = getHTMLElement(layer);
const renderBlock = jest.fn();
const portal = layer.renderPortal(renderBlock);
expect(portal).toBeTruthy();
expect(portal).toHaveProperty("$$typeof"); // React Portal symbol
expect(portal).toHaveProperty("containerInfo", _htmlElement);
expect(portal).toHaveProperty("key", "graph-blocks-list");
});
it("should pass correct props to BlocksList", () => {
const layer = createLayer();
const _htmlElement = getHTMLElement(layer);
const renderBlock = jest.fn();
const portal = layer.renderPortal(renderBlock);
expect(portal).toBeTruthy();
expect(portal).toHaveProperty("containerInfo", _htmlElement);
expect(portal).toHaveProperty("key", "graph-blocks-list");
// Check that children is a React element (BlocksList)
expect(portal.children).toBeTruthy();
});
});
describe("error handling", () => {
it("should handle applyBlockListClassName when HTML element is null", () => {
const layer = createLayer("test-class");
// Call applyBlockListClassName before attachLayer
expect(() => {
layer.attachLayer(rootElement);
}).not.toThrow();
});
it("should handle props changes gracefully", () => {
const layer = createLayer("initial-class");
const _htmlElement = getHTMLElement(layer);
// Test multiple rapid changes
expect(() => {
layer.setProps({ blockListClassName: "class1" });
layer.iterate();
layer.setProps({ blockListClassName: "class2" });
layer.iterate();
layer.setProps({ blockListClassName: undefined });
layer.iterate();
}).not.toThrow();
});
});
describe("lifecycle", () => {
it("should apply blockListClassName when attachLayer is called", () => {
const className = "test-class";
const layer = createUnattachedLayer(className);
// Before attachment - HTML element exists but blockListClassName is not applied
// because afterInit() hasn't been called yet
const htmlElementBefore = layer.getHTML();
expect(htmlElementBefore).toBeTruthy();
expect(htmlElementBefore.parentNode).toBeNull();
expect(htmlElementBefore.classList.contains(className)).toBe(false);
// After attachLayer - HTML element should be in DOM and have the class
// because attachLayer calls afterInit() which applies blockListClassName
layer.attachLayer(rootElement);
const htmlElementAfter = layer.getHTML();
expect(htmlElementAfter.classList.contains(className)).toBe(true);
expect(htmlElementAfter.parentNode).toBe(rootElement);
});
it("should handle detach and reattach correctly", () => {
const className = "test-class";
const layer = createUnattachedLayer(className);
// First attachment
layer.attachLayer(rootElement);
let htmlElement = layer.getHTML();
expect(htmlElement.classList.contains(className)).toBe(true);
// Detach
layer.detachLayer();
// Reattach
const newRoot = document.createElement("div");
document.body.appendChild(newRoot);
layer.attachLayer(newRoot);
htmlElement = layer.getHTML();
expect(htmlElement.classList.contains(className)).toBe(true);
// Cleanup
document.body.removeChild(newRoot);
});
});
describe("integration", () => {
it("should work with React StrictMode", () => {
// Test that the layer works correctly when React.StrictMode causes double rendering
const layer = createLayer("test-class");
const htmlElement = getHTMLElement(layer);
// Simulate double initialization that might happen in StrictMode
layer.attachLayer(rootElement);
layer.attachLayer(rootElement);
expect(htmlElement.classList.contains("test-class")).toBe(true);
// Should not have duplicate classes
const testClassCount = Array.from(htmlElement.classList).filter((cls) => cls === "test-class").length;
expect(testClassCount).toBe(1);
});
it("should handle camera transformations", () => {
const layer = createLayer("test-class");
const htmlElement = getHTMLElement(layer);
// Check that camera transformation class is applied
expect(htmlElement.classList.contains("layer-with-camera")).toBe(true);
});
});
});