diff --git a/src/components/ConsoleModal.test.tsx b/src/components/ConsoleModal.test.tsx new file mode 100644 index 00000000..033553d4 --- /dev/null +++ b/src/components/ConsoleModal.test.tsx @@ -0,0 +1,289 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import ConsoleModal from "./ConsoleModal"; +import type { StoreModel } from "../store/model"; +import { useStoreState, useStoreActions } from "../hooks"; + +// Mock the Console component +vi.mock("../containers/Console", () => ({ + default: vi.fn(() =>
Console Content
), +})); + +// Mock the hooks +const mockSetShowConsole = vi.fn(); +const mockSetPreferredView = vi.fn(); + +vi.mock("../hooks", () => ({ + useStoreState: vi.fn(), + useStoreActions: vi.fn(), +})); + +describe("ConsoleModal", () => { + let mockClipboard: { writeText: ReturnType }; + let createElementSpy: ReturnType; + let appendChildSpy: ReturnType; + let removeChildSpy: ReturnType; + let clickSpy: ReturnType; + let createObjectURLSpy: ReturnType; + let revokeObjectURLSpy: ReturnType; + let execCommandSpy: ReturnType; + let showConsoleValue: boolean; + let lammpsOutputValue: string[]; + + beforeEach(() => { + // Mock clipboard API + mockClipboard = { + writeText: vi.fn().mockResolvedValue(undefined), + }; + Object.defineProperty(navigator, "clipboard", { + value: mockClipboard, + writable: true, + configurable: true, + }); + + // Mock document methods for download + createElementSpy = vi.spyOn(document, "createElement"); + appendChildSpy = vi.spyOn(document.body, "appendChild"); + removeChildSpy = vi.spyOn(document.body, "removeChild"); + clickSpy = vi.fn(); + execCommandSpy = vi.spyOn(document, "execCommand").mockReturnValue(true); + + // Mock URL methods + createObjectURLSpy = vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:url"); + revokeObjectURLSpy = vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {}); + + // Mock link element + const mockLink = { + href: "", + download: "", + click: clickSpy, + } as Partial as HTMLAnchorElement; + createElementSpy.mockReturnValue(mockLink); + + // Setup store mocks - useStoreState is called multiple times with different selectors + vi.mocked(useStoreState).mockImplementation((selector: (state: StoreModel) => unknown) => { + // Check which property is being accessed by calling the selector with a mock state + const mockState = { + simulation: { + showConsole: showConsoleValue, + lammpsOutput: lammpsOutputValue, + }, + } as StoreModel; + return selector(mockState); + }); + + vi.mocked(useStoreActions).mockImplementation(() => ({ + simulation: { + setShowConsole: mockSetShowConsole, + }, + app: { + setPreferredView: mockSetPreferredView, + }, + })); + }); + + afterEach(() => { + vi.clearAllMocks(); + vi.restoreAllMocks(); + }); + + describe("rendering", () => { + it("should not render when showConsole is false", () => { + // Arrange + showConsoleValue = false; + lammpsOutputValue = []; + + // Act + const { container } = render(); + + // Assert + expect(container.firstChild).toBeNull(); + }); + + it("should render modal when showConsole is true", () => { + // Arrange + showConsoleValue = true; + lammpsOutputValue = ["log line 1", "log line 2"]; + + // Act + render(); + + // Assert + expect(screen.getByTestId("console")).toBeInTheDocument(); + expect(screen.getByText("Download logs")).toBeInTheDocument(); + expect(screen.getByText("Copy logs")).toBeInTheDocument(); + expect(screen.getByText("Analyze in notebook")).toBeInTheDocument(); + expect(screen.getByText("Close")).toBeInTheDocument(); + }); + }); + + describe("download logs", () => { + it("should download logs as text file when Download logs button is clicked", async () => { + // Arrange + showConsoleValue = true; + lammpsOutputValue = ["line 1", "line 2", "line 3"]; + + render(); + const downloadButton = screen.getByText("Download logs"); + + // Act + await userEvent.click(downloadButton); + + // Assert + expect(createElementSpy).toHaveBeenCalledWith("a"); + expect(appendChildSpy).toHaveBeenCalled(); + expect(clickSpy).toHaveBeenCalled(); + expect(removeChildSpy).toHaveBeenCalled(); + expect(revokeObjectURLSpy).toHaveBeenCalledWith("blob:url"); + + // Verify blob creation + const blobCalls = createObjectURLSpy.mock.calls; + expect(blobCalls.length).toBeGreaterThan(0); + const blob = blobCalls[0][0] as Blob; + expect(blob).toBeInstanceOf(Blob); + expect(blob.type).toBe("text/plain"); + }); + + it("should generate filename with current date", async () => { + // Arrange + showConsoleValue = true; + lammpsOutputValue = ["test log"]; + const mockDate = new Date("2024-01-15T12:00:00Z"); + vi.spyOn(global, "Date").mockImplementation(() => mockDate as unknown as Date); + vi.spyOn(mockDate, "toISOString").mockReturnValue("2024-01-15T12:00:00.000Z"); + + render(); + const downloadButton = screen.getByText("Download logs"); + + // Act + await userEvent.click(downloadButton); + + // Assert + const linkElement = createElementSpy.mock.results[0].value as HTMLAnchorElement; + expect(linkElement.download).toBe("lammps-logs-2024-01-15.txt"); + }); + }); + + describe("copy logs", () => { + it("should copy logs to clipboard when Copy logs button is clicked", async () => { + // Arrange + showConsoleValue = true; + lammpsOutputValue = ["line 1", "line 2"]; + + render(); + const copyButton = screen.getByText("Copy logs"); + + // Act + await userEvent.click(copyButton); + + // Assert + await waitFor(() => { + expect(mockClipboard.writeText).toHaveBeenCalledWith("line 1\nline 2"); + }); + }); + + it("should use fallback method when clipboard API fails", async () => { + // Arrange + showConsoleValue = true; + lammpsOutputValue = ["fallback test"]; + mockClipboard.writeText.mockRejectedValue(new Error("Clipboard API not available")); + + // Mock textarea element for fallback + const mockTextArea = { + value: "", + style: {} as CSSStyleDeclaration, + select: vi.fn(), + } as Partial as HTMLTextAreaElement; + createElementSpy.mockReturnValueOnce(mockTextArea); + + render(); + const copyButton = screen.getByText("Copy logs"); + + // Act + await userEvent.click(copyButton); + + // Assert + await waitFor(() => { + expect(createElementSpy).toHaveBeenCalledWith("textarea"); + expect(mockTextArea.value).toBe("fallback test"); + expect(appendChildSpy).toHaveBeenCalled(); + expect(mockTextArea.select).toHaveBeenCalled(); + expect(execCommandSpy).toHaveBeenCalledWith("copy"); + expect(removeChildSpy).toHaveBeenCalled(); + }); + }); + }); + + describe("analyze in notebook", () => { + it("should close modal and set preferred view to notebook when Analyze in notebook button is clicked", async () => { + // Arrange + showConsoleValue = true; + lammpsOutputValue = []; + + render(); + const analyzeButton = screen.getByText("Analyze in notebook"); + + // Act + await userEvent.click(analyzeButton); + + // Assert + expect(mockSetShowConsole).toHaveBeenCalledWith(false); + expect(mockSetPreferredView).toHaveBeenCalledTimes(2); + expect(mockSetPreferredView).toHaveBeenNthCalledWith(1, undefined); + expect(mockSetPreferredView).toHaveBeenNthCalledWith(2, "notebook"); + }); + }); + + describe("close", () => { + it("should close modal when Close button is clicked", async () => { + // Arrange + showConsoleValue = true; + lammpsOutputValue = []; + + render(); + const closeButton = screen.getByText("Close"); + + // Act + await userEvent.click(closeButton); + + // Assert + expect(mockSetShowConsole).toHaveBeenCalledWith(false); + }); + + it("should close modal when onCancel is triggered", async () => { + // Arrange + showConsoleValue = true; + lammpsOutputValue = []; + + render(); + const modal = screen.getByRole("dialog"); + + // Act - simulate ESC key or backdrop click + await userEvent.keyboard("{Escape}"); + + // Assert + expect(mockSetShowConsole).toHaveBeenCalledWith(false); + }); + }); + + describe("console key update", () => { + it("should update console key when showConsole changes to true", async () => { + // Arrange + showConsoleValue = false; + lammpsOutputValue = []; + + const { rerender } = render(); + expect(screen.queryByTestId("console")).not.toBeInTheDocument(); + + // Act - change showConsole to true + showConsoleValue = true; + rerender(); + + // Assert + await waitFor(() => { + expect(screen.getByTestId("console")).toBeInTheDocument(); + }); + }); + }); +}); diff --git a/src/components/ConsoleModal.tsx b/src/components/ConsoleModal.tsx new file mode 100644 index 00000000..f02fcc10 --- /dev/null +++ b/src/components/ConsoleModal.tsx @@ -0,0 +1,120 @@ +import { Modal, Button } from "antd"; +import { useState, useEffect } from "react"; +import Console from "../containers/Console"; +import { useStoreActions, useStoreState } from "../hooks"; + +const ConsoleModal = () => { + const showConsole = useStoreState((state) => state.simulation.showConsole); + const lammpsOutput = useStoreState((state) => state.simulation.lammpsOutput); + const [consoleKey, setConsoleKey] = useState(0); + const setShowConsole = useStoreActions( + (actions) => actions.simulation.setShowConsole, + ); + const setPreferredView = useStoreActions( + (actions) => actions.app.setPreferredView, + ); + + // Update console key when modal opens + useEffect(() => { + if (showConsole) { + setConsoleKey(Date.now()); + } + }, [showConsole]); + + // Handle download logs + const handleDownloadLogs = () => { + const logsText = lammpsOutput.join("\n"); + const blob = new Blob([logsText], { type: "text/plain" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `lammps-logs-${new Date().toISOString().split("T")[0]}.txt`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + }; + + // Handle copy logs + const handleCopyLogs = async () => { + const logsText = lammpsOutput.join("\n"); + try { + await navigator.clipboard.writeText(logsText); + } catch (err) { + // Fallback for older browsers + const textArea = document.createElement("textarea"); + textArea.value = logsText; + textArea.style.position = "fixed"; + textArea.style.opacity = "0"; + document.body.appendChild(textArea); + textArea.select(); + document.execCommand("copy"); + document.body.removeChild(textArea); + } + }; + + // Handle analyze in notebook + const handleAnalyzeInNotebook = () => { + setShowConsole(false); + setPreferredView(undefined); + setPreferredView("notebook"); + }; + + // Handle close + const handleClose = () => { + setShowConsole(false); + }; + + if (!showConsole) { + return null; + } + + return ( + +
+ + +
+
+ + +
+ + } + closable={false} + open + onCancel={handleClose} + > + +
+ ); +}; + +export default ConsoleModal; diff --git a/src/containers/Main.tsx b/src/containers/Main.tsx index 73e1124d..249b5e20 100644 --- a/src/containers/Main.tsx +++ b/src/containers/Main.tsx @@ -1,4 +1,4 @@ -import { Modal, Tabs, Progress, Button, Layout } from "antd"; +import { Modal, Tabs, Progress, Layout } from "antd"; import { useState, useEffect, useMemo } from "react"; import View from "./View"; import Notebook from "./Notebook"; @@ -7,32 +7,17 @@ import Console from "./Console"; import Examples from "./Examples"; import RunInCloud from "./RunInCloud"; import LoadingSimulationScreen from "../components/LoadingSimulationScreen"; -import { useStoreActions, useStoreState } from "../hooks"; +import ConsoleModal from "../components/ConsoleModal"; +import { useStoreState } from "../hooks"; const { Content } = Layout; const Main = ({ isEmbedded }: { isEmbedded: boolean }) => { // @ts-ignore const wasm = window.wasm; // TODO: This is an ugly hack because wasm object is so big that Redux debugger hangs. - const showConsole = useStoreState((state) => state.simulation.showConsole); - const [consoleKey, setConsoleKey] = useState(0); const [hasStarted, setHasStarted] = useState(false); - const setShowConsole = useStoreActions( - (actions) => actions.simulation.setShowConsole, - ); const selectedMenu = useStoreState((state) => state.app.selectedMenu); const running = useStoreState((state) => state.simulation.running); - - const setPreferredView = useStoreActions( - (actions) => actions.app.setPreferredView, - ); const status = useStoreState((state) => state.app.status); - - // Update console key when modal opens - useEffect(() => { - if (showConsole) { - setConsoleKey(Date.now()); - } - }, [showConsole]); // Track when simulation has started useEffect(() => { @@ -93,35 +78,7 @@ const Main = ({ isEmbedded }: { isEmbedded: boolean }) => { renderTabBar={() => <>} items={tabs} /> - {showConsole && ( - - - - , - ]} - closable={false} - open - onCancel={() => setShowConsole(false)} - > - - - )} + {!isEmbedded && (