import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { Settings } from "../Settings"; import type { MonitoringMachine } from "../../types"; const saveMachineMutate = vi.fn().mockResolvedValue({}); const deleteMachineMutate = vi.fn(); const testSSHMutate = vi.fn().mockResolvedValue({ message: "SSH auth succeeded", known_hosts_updated: true, }); let machines: MonitoringMachine[] = []; vi.mock("../../hooks/useSettings", () => ({ useMonitoringSettings: () => ({ data: machines }), useSSHKeys: () => ({ data: [] }), useSaveMonitoringMachine: () => ({ mutateAsync: saveMachineMutate, isPending: false, }), useDeleteMonitoringMachine: () => ({ mutate: deleteMachineMutate }), useTestMonitoringMachineSSH: () => ({ mutateAsync: testSSHMutate, isPending: false, }), useResetLocalDatabase: () => ({}), useSaveSSHKey: () => ({ mutateAsync: vi.fn() }), useGenerateSSHKey: () => ({ mutateAsync: vi.fn(), isPending: false }), useDeleteSSHKey: () => ({ mutate: vi.fn() }), })); function localMachine( overrides: Partial = {}, ): MonitoringMachine { return { id: "m1", name: "This machine", mode: "local", enabled: true, services: ["monitoring", "files", "jellyfin"], host: "", port: 22, username: "", key_directory: "", key_name: "", ssh_key_id: "", ssh_private_key_set: false, ssh_private_key_passphrase_set: false, password_set: false, notes: "Primary node", ...overrides, } as MonitoringMachine; } beforeEach(() => { saveMachineMutate.mockClear(); deleteMachineMutate.mockClear(); testSSHMutate.mockClear(); machines = []; }); describe("Settings", () => { it("renders the machine list from the mocked store", () => { machines = [localMachine()]; render(); // The rail row caption (mode · enabled) is unique to the selection rail. expect(screen.getByText("local · Enabled")).toBeInTheDocument(); }); it("saves a machine via the editor dialog (controlled useState parity)", async () => { machines = [localMachine()]; render(); // The detail-pane "Edit" has visible text "Edit"; the rail hover edit // affordance is icon-only (aria-label "Edit") — disambiguate by text. const detailEdit = screen .getAllByRole("button", { name: "Edit" }) .find((button) => button.textContent === "Edit") as HTMLButtonElement; await userEvent.click(detailEdit); expect(screen.getByText("Edit machine")).toBeInTheDocument(); // Rename through the labeled field, then save. const nameInput = screen.getByLabelText("Name"); await userEvent.clear(nameInput); await userEvent.type(nameInput, "Worker node"); await userEvent.click(screen.getByRole("button", { name: "Save machine" })); expect(saveMachineMutate).toHaveBeenCalledTimes(1); const saved = saveMachineMutate.mock.calls[0][0]; expect(saved.name).toBe("Worker node"); expect(saved.mode).toBe("local"); }); it("deletes a machine through the confirm dialog", async () => { machines = [localMachine()]; render(); // Detail-pane "Delete" opens the confirm dialog. await userEvent.click(screen.getByRole("button", { name: "Delete" })); expect(screen.getByText("Delete machine?")).toBeInTheDocument(); // Confirm (the confirm dialog's "Delete" is the last one rendered). const deletes = screen.getAllByRole("button", { name: "Delete" }); await userEvent.click(deletes[deletes.length - 1]); expect(deleteMachineMutate).toHaveBeenCalledTimes(1); expect(deleteMachineMutate).toHaveBeenCalledWith("m1"); }); }); // jsdom has no window.matchMedia; default to desktop so existing tests are // unaffected. function setMatchMedia(matches: boolean) { window.matchMedia = ((query: string) => ({ matches: query.includes("768") ? matches : false, media: query, onchange: null, addEventListener: () => {}, removeEventListener: () => {}, addListener: () => {}, removeListener: () => {}, dispatchEvent: () => false, })) as unknown as typeof window.matchMedia; } describe("Settings (mobile SheetForm — slice 7)", () => { beforeEach(() => setMatchMedia(true)); it("opens the machine editor in a SheetForm below md", async () => { machines = [localMachine()]; render(); // Open the editor via the detail-pane Edit button (visible text). const detailEdit = screen .getAllByRole("button", { name: "Edit" }) .find((button) => button.textContent === "Edit") as HTMLButtonElement; await userEvent.click(detailEdit); // SheetForm renders a dialog; the DialogTitle shows the editor title. expect(screen.getByText("Edit machine")).toBeInTheDocument(); expect(screen.getByRole("dialog")).toBeInTheDocument(); // Desktop DialogDescription text is not rendered as a dialog description // on mobile (the MachineEditor has its own hint labels, which is fine). expect( screen.queryByRole("heading", { name: "Create machine" }), ).not.toBeInTheDocument(); }); it("saves a machine via the SheetForm on mobile", async () => { machines = [localMachine()]; render(); const detailEdit = screen .getAllByRole("button", { name: "Edit" }) .find((button) => button.textContent === "Edit") as HTMLButtonElement; await userEvent.click(detailEdit); const nameInput = screen.getByLabelText("Name"); await userEvent.clear(nameInput); await userEvent.type(nameInput, "Renamed node"); await userEvent.click(screen.getByRole("button", { name: "Save machine" })); expect(saveMachineMutate).toHaveBeenCalledTimes(1); const saved = saveMachineMutate.mock.calls[0][0]; expect(saved.name).toBe("Renamed node"); expect(saved.mode).toBe("local"); }); it("cancel closes the SheetForm on mobile", async () => { machines = [localMachine()]; render(); const detailEdit = screen .getAllByRole("button", { name: "Edit" }) .find((button) => button.textContent === "Edit") as HTMLButtonElement; await userEvent.click(detailEdit); expect(screen.getByRole("dialog")).toBeInTheDocument(); await userEvent.click(screen.getByRole("button", { name: "Cancel" })); // The sheet is now closed — the dialog role should no longer be present. // (The page content itself is still rendered; only the sheet unmounts.) expect(screen.queryByText("Edit machine")).not.toBeInTheDocument(); }); it("prompts before discarding unsaved machine edits (R4.5)", async () => { machines = [localMachine()]; render(); const detailEdit = screen .getAllByRole("button", { name: "Edit" }) .find((button) => button.textContent === "Edit") as HTMLButtonElement; await userEvent.click(detailEdit); // Edit the name to make the form dirty. const nameInput = screen.getByLabelText("Name"); await userEvent.clear(nameInput); await userEvent.type(nameInput, "Dirty name"); // Cancel should NOT immediately close — the discard confirm appears. await userEvent.click(screen.getByRole("button", { name: "Cancel" })); expect( screen.getByRole("heading", { name: "Discard changes?" }), ).toBeInTheDocument(); // The editor is still open. expect(screen.getByText("Edit machine")).toBeInTheDocument(); }); });