diff --git a/src/containers/View.test.tsx b/src/containers/View.test.tsx new file mode 100644 index 00000000..883d3146 --- /dev/null +++ b/src/containers/View.test.tsx @@ -0,0 +1,591 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, act, fireEvent } from "@testing-library/react"; +import View from "./View"; +import { useStoreState, useStoreActions } from "../hooks"; +import { useEmbeddedMode } from "../hooks/useEmbeddedMode"; +import type { EmbeddedModeResult } from "../hooks/useEmbeddedMode"; +import { Visualizer } from "omovi"; + +// Mock omovi: prevents WebGL/canvas errors in jsdom +vi.mock("omovi", () => ({ + Visualizer: vi.fn(), + Particles: vi.fn(), + Bonds: vi.fn(), +})); + +// Mock hooks: allows flexible per-test control of store state and actions +vi.mock("../hooks", () => ({ + useStoreState: vi.fn(), + useStoreActions: vi.fn(), +})); + +// Mock useEmbeddedMode: controls embed configuration per test +vi.mock("../hooks/useEmbeddedMode", () => ({ + useEmbeddedMode: vi.fn(), +})); + +// Mock THREE.js: prevents canvas rendering errors in jsdom +vi.mock("three", () => ({ + Group: vi.fn(() => ({ traverse: vi.fn(), parent: null })), + Mesh: class {}, + Material: class {}, + Box3: vi.fn(), + Vector3: vi.fn(), +})); + +// Mock box/wall geometry utilities: avoids THREE.js geometry construction +vi.mock("../utils/boxGeometry", () => ({ + createBoxGeometry: vi.fn(() => ({ traverse: vi.fn(), parent: null })), + calculateBoxRadius: vi.fn(() => 1), + getSimulationBoxBounds: vi.fn(() => ({})), +})); + +vi.mock("../utils/wallGeometry", () => ({ + createWallGroup: vi.fn(() => ({ traverse: vi.fn(), parent: null })), +})); + +// Mock metrics: prevents mixpanel/localStorage side effects from track() +vi.mock("../utils/metrics", () => ({ + track: vi.fn(), +})); + +// Mock child components: reduces render complexity and dependency surface +vi.mock("../components/ResponsiveSimulationSummary", () => ({ + default: (_props: any) => ( +
+ ), +})); + +vi.mock("../components/SelectedAtomsInfo", () => ({ + default: ({ onClearSelection }: { onClearSelection: () => void }) => ( + + ), +})); + +vi.mock("../components/ColorLegend", () => ({ + default: () =>
, +})); + +vi.mock("../modifiers/ColorModifierSettings", () => ({ + default: () =>
, +})); + +// Mock antd: uses a simplified DOM structure; Layout.Header is a static property +vi.mock("antd", () => { + const LayoutMock: any = ({ children, style }: any) => ( +
{children}
+ ); + LayoutMock.Header = ({ children, className, style }: any) => ( +
+ {children} +
+ ); + + return { + Layout: LayoutMock, + Row: ({ children }: any) =>
{children}
, + Col: ({ children }: any) =>
{children}
, + Progress: () =>
, + Modal: ({ children, open, onCancel, title, footer }: any) => + open ? ( +
+
{title}
+ {children} +
{footer}
+
+ ) : null, + Button: ({ children, onClick }: any) => ( + + ), + }; +}); + +// --- Shared mutable state (updated in beforeEach; closures capture by reference) --- +let mockState: ReturnType; +let mockActions: ReturnType; + +describe("View", () => { + beforeEach(() => { + mockState = createDefaultMockState(); + mockActions = createDefaultMockActions(); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.mocked(useStoreState).mockImplementation((selector: any) => + selector(mockState), + ); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.mocked(useStoreActions).mockImplementation((selector: any) => + selector(mockActions), + ); + vi.mocked(useEmbeddedMode).mockReturnValue(createDefaultEmbedModeResult()); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + // --------------------------------------------------------------------------- + // P0: Auto-clear selection on simulation change + // --------------------------------------------------------------------------- + describe("P0: Auto-clear selection on simulation change", () => { + it("should call clearSelection on initial mount", () => { + // Arrange + const mockVisualizer = mockState.render.visualizer; + + // Act + render(); + + // Assert: the auto-clear effect fires once on mount + expect(mockVisualizer.clearSelection).toHaveBeenCalled(); + }); + + it("should call clearSelection again when simulation value changes", () => { + // Arrange: start with no simulation + mockState.simulation.simulation = null; + const mockVisualizer = mockState.render.visualizer; + const { rerender } = render(); + + const callsAfterMount = mockVisualizer.clearSelection.mock.calls.length; + + // Act: change simulation to a new value + mockState.simulation.simulation = { id: "sim1" }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.mocked(useStoreState).mockImplementation((selector: any) => + selector(mockState), + ); + rerender(); + + // Assert: clearSelection called at least once more + expect(mockVisualizer.clearSelection.mock.calls.length).toBeGreaterThan( + callsAfterMount, + ); + }); + + it("should call clearSelection when simulation changes from one to another", () => { + // Arrange: start with sim1 + mockState.simulation.simulation = { id: "sim1" }; + const mockVisualizer = mockState.render.visualizer; + const { rerender } = render(); + + const callsAfterFirstRender = + mockVisualizer.clearSelection.mock.calls.length; + + // Act: switch to sim2 + mockState.simulation.simulation = { id: "sim2" }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.mocked(useStoreState).mockImplementation((selector: any) => + selector(mockState), + ); + rerender(); + + // Assert + expect(mockVisualizer.clearSelection.mock.calls.length).toBeGreaterThan( + callsAfterFirstRender, + ); + }); + }); + + // --------------------------------------------------------------------------- + // P1: Escape key behavior + // --------------------------------------------------------------------------- + describe("P1: Escape key behavior", () => { + it("should NOT call clearSelection when Escape is pressed with no atoms selected", () => { + // Arrange + const mockVisualizer = mockState.render.visualizer; + render(); + + const callsAfterMount = mockVisualizer.clearSelection.mock.calls.length; + + // Act: press Escape with selectedAtoms still empty (initial state) + act(() => { + fireEvent.keyDown(window, { key: "Escape" }); + }); + + // Assert: no additional clearSelection call + expect(mockVisualizer.clearSelection.mock.calls.length).toBe( + callsAfterMount, + ); + }); + + it("should call clearSelection when Escape is pressed with atoms selected", () => { + // Arrange: provide null visualizer so the component creates one, + // capturing the onParticleClick callback to simulate a selection. + let capturedOnParticleClick: + | ((event: { particleIndex: number; shiftKey: boolean }) => void) + | undefined; + const mockVisualizerInstance = createMockVisualizerInstance(); + + // Use a regular function (not arrow) so it can be called with `new` + vi.mocked(Visualizer).mockImplementation( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + function (this: any, options?: any) { + capturedOnParticleClick = options?.onParticleClick; + // Update mockState immediately to prevent infinite re-creation loop + mockState.render.visualizer = mockVisualizerInstance; + return mockVisualizerInstance as any; + }, + ); + + mockState.render.visualizer = null; + + render(); + + // onParticleClick should now be captured from the Visualizer constructor + expect(capturedOnParticleClick).toBeDefined(); + + // Simulate a particle click to add atom 5 to selectedAtoms + act(() => { + capturedOnParticleClick!({ particleIndex: 5, shiftKey: false }); + }); + + // Record calls before Escape (onParticleClick also calls clearSelection internally) + const callsBeforeEscape = + mockVisualizerInstance.clearSelection.mock.calls.length; + + // Act: press Escape — selectedAtoms.size > 0, so handleClearSelection fires + act(() => { + fireEvent.keyDown(window, { key: "Escape" }); + }); + + // Assert: at least one more clearSelection call from the Escape handler + expect( + mockVisualizerInstance.clearSelection.mock.calls.length, + ).toBeGreaterThan(callsBeforeEscape); + }); + }); + + // --------------------------------------------------------------------------- + // P1: No simulation modal in embedded mode + // --------------------------------------------------------------------------- + describe("P1: No simulation modal in embedded mode", () => { + it("should NOT show the no-simulation modal when isEmbeddedMode is true", () => { + // Arrange: simulation is null (would normally trigger the modal) + mockState.simulation.simulation = null; + + // Act + render(); + + // Assert: modal not rendered + expect( + screen.queryByTestId("no-simulation-modal"), + ).not.toBeInTheDocument(); + }); + + it("should show the no-simulation modal when not embedded and simulation is null", () => { + // Arrange + mockState.simulation.simulation = null; + + // Act + render(); + + // Assert + expect(screen.getByTestId("no-simulation-modal")).toBeInTheDocument(); + }); + + it("should NOT show the modal when simulation exists", () => { + // Arrange + mockState.simulation.simulation = { id: "sim1" }; + + // Act + render(); + + // Assert + expect( + screen.queryByTestId("no-simulation-modal"), + ).not.toBeInTheDocument(); + }); + }); + + // --------------------------------------------------------------------------- + // P2: showAnalyze localStorage persistence + // --------------------------------------------------------------------------- + describe("P2: showAnalyze localStorage persistence", () => { + let mockLocalStorage: Record; + + beforeEach(() => { + mockLocalStorage = {}; + vi.spyOn(Storage.prototype, "getItem").mockImplementation( + (key) => mockLocalStorage[key] ?? null, + ); + vi.spyOn(Storage.prototype, "setItem").mockImplementation( + (key, value) => { + mockLocalStorage[key] = value; + }, + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("should read showAnalyze from localStorage on mount", () => { + // Arrange: pre-populate localStorage + mockLocalStorage["simulationSummaryDrawerVisible"] = "true"; + + // Act + render(); + + // Assert: getItem was called with the persistence key + expect(Storage.prototype.getItem).toHaveBeenCalledWith( + "simulationSummaryDrawerVisible", + ); + }); + + it("should persist showAnalyze to localStorage when not in embedded mode", () => { + // Act + render(); + + // Assert: setItem called with the persistence key + expect(Storage.prototype.setItem).toHaveBeenCalledWith( + "simulationSummaryDrawerVisible", + expect.any(String), + ); + }); + + it("should NOT write showAnalyze to localStorage when in embedded mode", () => { + // Arrange + vi.mocked(useEmbeddedMode).mockReturnValue({ + ...createDefaultEmbedModeResult(), + isEmbeddedMode: true, + embedConfig: { + showSimulationSummary: true, + showSimulationBox: true, + enableCameraControls: true, + enableParticlePicking: true, + }, + }); + + // Act + render(); + + // Assert: setItem NOT called with the drawer visibility key + expect(Storage.prototype.setItem).not.toHaveBeenCalledWith( + "simulationSummaryDrawerVisible", + expect.any(String), + ); + }); + }); + + // --------------------------------------------------------------------------- + // P2: embedConfig.showSimulationSummary overrides + // --------------------------------------------------------------------------- + describe("P2: embedConfig.showSimulationSummary overrides", () => { + it("should NOT render ResponsiveSimulationSummary when embedded and showSimulationSummary=false", () => { + // Arrange + vi.mocked(useEmbeddedMode).mockReturnValue({ + ...createDefaultEmbedModeResult(), + embedConfig: { + showSimulationSummary: false, + showSimulationBox: true, + enableCameraControls: true, + enableParticlePicking: true, + }, + }); + + // Act + render(); + + // Assert + expect( + screen.queryByTestId("responsive-simulation-summary"), + ).not.toBeInTheDocument(); + }); + + it("should render ResponsiveSimulationSummary when embedded and showSimulationSummary=true", () => { + // Arrange + vi.mocked(useEmbeddedMode).mockReturnValue({ + ...createDefaultEmbedModeResult(), + embedConfig: { + showSimulationSummary: true, + showSimulationBox: true, + enableCameraControls: true, + enableParticlePicking: true, + }, + }); + + // Act + render(); + + // Assert + expect( + screen.getByTestId("responsive-simulation-summary"), + ).toBeInTheDocument(); + }); + + it("should render ResponsiveSimulationSummary in non-embedded mode regardless of embedConfig", () => { + // Arrange: embedConfig says false, but non-embedded ignores it + vi.mocked(useEmbeddedMode).mockReturnValue({ + ...createDefaultEmbedModeResult(), + embedConfig: { + showSimulationSummary: false, + showSimulationBox: true, + enableCameraControls: true, + enableParticlePicking: true, + }, + }); + + // Act + render(); + + // Assert: non-embedded always shows the summary + expect( + screen.getByTestId("responsive-simulation-summary"), + ).toBeInTheDocument(); + }); + }); + + // --------------------------------------------------------------------------- + // P2: visualizer.idle toggled by visible prop + // --------------------------------------------------------------------------- + describe("P2: visualizer.idle toggled by visible prop", () => { + it("should set visualizer.idle=false when visible=true", () => { + // Arrange + const mockVisualizer = mockState.render.visualizer; + mockVisualizer.idle = true; + + // Act + render(); + + // Assert + expect(mockVisualizer.idle).toBe(false); + }); + + it("should set visualizer.idle=true when visible=false", () => { + // Arrange + const mockVisualizer = mockState.render.visualizer; + mockVisualizer.idle = false; + + // Act + render(); + + // Assert + expect(mockVisualizer.idle).toBe(true); + }); + + it("should update visualizer.idle when visible prop changes", () => { + // Arrange + const mockVisualizer = mockState.render.visualizer; + const { rerender } = render(); + expect(mockVisualizer.idle).toBe(false); + + // Act: hide + rerender(); + + // Assert + expect(mockVisualizer.idle).toBe(true); + + // Act: show again + rerender(); + + // Assert + expect(mockVisualizer.idle).toBe(false); + }); + }); +}); + +// --------------------------------------------------------------------------- +// Helper functions +// --------------------------------------------------------------------------- + +function createMockVisualizerInstance() { + return { + clearSelection: vi.fn(), + setSelected: vi.fn(), + add: vi.fn(), + remove: vi.fn(), + dispose: vi.fn(), + idle: false as boolean, + scene: { add: vi.fn(), remove: vi.fn() }, + setCameraPosition: vi.fn(), + setCameraTarget: vi.fn(), + isPostProcessingEnabled: vi.fn(() => false), + updatePostProcessingSettings: vi.fn(), + initPostProcessing: vi.fn(), + pointLight: { intensity: 1.0 }, + ambientLight: { intensity: 0.5 }, + setOrthographic: vi.fn(), + setControlsEnabled: vi.fn(), + setPickingEnabled: vi.fn(), + materials: { particles: { shininess: 0 } }, + enableXR: vi.fn(() => null), + }; +} + +function createDefaultMockState() { + return { + simulation: { + cameraPosition: null as any, + cameraTarget: null as any, + running: false, + simulation: null as any, + }, + render: { + particles: null as any, + bonds: null as any, + visualizer: createMockVisualizerInstance() as any, + }, + settings: { + render: { + ssao: false, + ssaoRadius: 0.5, + ssaoIntensity: 0.5, + pointLightIntensity: 1.0, + ambientLightIntensity: 0.5, + orthographic: false, + showSimulationBox: false, + showWalls: false, + }, + }, + processing: { + postTimestepModifiers: [] as any[], + }, + simulationStatus: { + runTotalTimesteps: 0, + runTimesteps: 0, + timesteps: [] as any[], + box: undefined as any, + origo: undefined as any, + computes: {} as Record, + fixes: {} as Record, + variables: {} as Record, + dimension: 3, + walls: [] as any[], + }, + }; +} + +function createDefaultMockActions() { + return { + render: { + // Mirrors the real action: updates mockState so subsequent renders + // see the newly created visualizer and don't re-trigger creation. + setVisualizer: vi.fn((v: any) => { + mockState.render.visualizer = v; + }), + }, + settings: { + setRender: vi.fn(), + }, + }; +} + +function createDefaultEmbedModeResult(): EmbeddedModeResult { + return { + embeddedSimulationUrl: null, + simulationIndex: 0, + embeddedData: null, + autoStart: false, + isEmbeddedMode: false, + vars: {}, + embedConfig: { + showSimulationSummary: true, + showSimulationBox: true, + enableCameraControls: true, + enableParticlePicking: true, + }, + }; +} diff --git a/src/containers/View.tsx b/src/containers/View.tsx index 7b6c9090..431b9384 100644 --- a/src/containers/View.tsx +++ b/src/containers/View.tsx @@ -317,7 +317,7 @@ const View = ({ visible, isEmbeddedMode = false }: ViewProps) => { handleClearSelection(); }, [simulation, handleClearSelection]); - const prevParticlesRef = useRef(); + const prevParticlesRef = useRef(undefined); useEffect(() => { prevParticlesRef.current = particles; }); @@ -335,7 +335,7 @@ const View = ({ visible, isEmbeddedMode = false }: ViewProps) => { } }, [cameraTarget, visualizer]); - const prevBondsRef = useRef(); + const prevBondsRef = useRef(undefined); useEffect(() => { prevBondsRef.current = bonds; });