feat(frontend): slice 5 — migrate Settings + Actions to shadcn/Tailwind
Web UI rework. Form-heavy pair (controlled useState parity, no form lib): - pages/Settings.tsx off @mui: monitoring-machine CRUD, SSH-key mgmt, SSH test/validation feedback, danger-zone reset (ConfirmDialog), tabs - pages/Actions.tsx off @mui: saved-task editor, machine selection, run history, tabs - Both reuse migrated shared components (SectionCard/SelectionRailCard/ TabbedCard/HoverEditButton/ConfirmDialog/DialogFooter) as before - Behavioral tests added (mocked hooks; no live SSH) Gate: build + lint + test green (19 files / 39 tests).
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
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> = {},
|
||||
): 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,
|
||||
media_root: "/mnt/media",
|
||||
path_prefix: "",
|
||||
jellyfin_url: "",
|
||||
jellyfin_user_id: "",
|
||||
jellyfin_api_key_set: false,
|
||||
jellyseerr_url: "",
|
||||
jellyseerr_api_key_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(<Settings />);
|
||||
// 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(<Settings />);
|
||||
|
||||
// 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(<Settings />);
|
||||
|
||||
// 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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user