refactor(frontend): decompose ConfigPage into dedicated config components

- Extract tab components: JailsTab, ActionsTab, FiltersTab, JailFilesTab,
  GlobalTab, ServerTab, ConfFilesTab, RegexTesterTab, MapTab, ExportTab
- Add form components: JailFileForm, ActionForm, FilterForm
- Add AutoSaveIndicator, RegexList, configStyles, and barrel index
- ConfigPage now composes these components; greatly reduces file size
- Add tests: ConfigPage.test.tsx, useAutoSave.test.ts
This commit is contained in:
2026-03-13 13:48:09 +01:00
parent a0e8566ff8
commit 9b73f6719d
23 changed files with 4275 additions and 1828 deletions

View File

@@ -0,0 +1,144 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useAutoSave } from "../useAutoSave";
describe("useAutoSave", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("starts with idle status", () => {
const save = vi.fn().mockResolvedValue(undefined);
const { result } = renderHook(() => useAutoSave("initial", save));
expect(result.current.status).toBe("idle");
expect(result.current.errorText).toBeNull();
});
it("does not save immediately when value changes", () => {
const save = vi.fn().mockResolvedValue(undefined);
const { rerender } = renderHook(({ value }) => useAutoSave(value, save), {
initialProps: { value: "initial" },
});
rerender({ value: "changed" });
expect(save).not.toHaveBeenCalled();
});
it("calls save after debounce period", async () => {
const save = vi.fn().mockResolvedValue(undefined);
const { rerender } = renderHook(({ value }) => useAutoSave(value, save, { debounceMs: 500 }), {
initialProps: { value: "initial" },
});
rerender({ value: "changed" });
expect(save).not.toHaveBeenCalled();
act(() => { vi.advanceTimersByTime(500); });
await act(() => Promise.resolve());
expect(save).toHaveBeenCalledWith("changed");
});
it("transitions to saving then saved on success", async () => {
let resolveSave!: () => void;
const save = vi.fn().mockImplementation(
() => new Promise<void>((resolve) => { resolveSave = resolve; }),
);
const { result, rerender } = renderHook(
({ value }) => useAutoSave(value, save, { debounceMs: 100 }),
{ initialProps: { value: "v1" } },
);
rerender({ value: "v2" });
act(() => { vi.advanceTimersByTime(100); });
// Let the microtask queue run so the save call starts.
await act(() => Promise.resolve());
expect(result.current.status).toBe("saving");
await act(() => { resolveSave(); return Promise.resolve(); });
expect(result.current.status).toBe("saved");
});
it("transitions to error status on save failure", async () => {
const save = vi.fn().mockRejectedValue(new Error("network error"));
const { result, rerender } = renderHook(
({ value }) => useAutoSave(value, save, { debounceMs: 100 }),
{ initialProps: { value: "v1" } },
);
rerender({ value: "v2" });
act(() => { vi.advanceTimersByTime(100); });
// Wait for the save promise to reject and state to update.
await act(() => Promise.resolve());
await act(() => Promise.resolve());
expect(result.current.status).toBe("error");
expect(result.current.errorText).toBe("network error");
});
it("retry triggers another save attempt", async () => {
const save = vi.fn()
.mockRejectedValueOnce(new Error("first failure"))
.mockResolvedValue(undefined);
const { result, rerender } = renderHook(
({ value }) => useAutoSave(value, save, { debounceMs: 100 }),
{ initialProps: { value: "v1" } },
);
rerender({ value: "v2" });
act(() => { vi.advanceTimersByTime(100); });
await act(() => Promise.resolve());
await act(() => Promise.resolve());
expect(result.current.status).toBe("error");
await act(() => { result.current.retry(); return Promise.resolve(); });
await act(() => Promise.resolve());
expect(result.current.status).toBe("saved");
expect(save).toHaveBeenCalledTimes(2);
});
it("debounces rapid value changes — calls save only once", async () => {
const save = vi.fn().mockResolvedValue(undefined);
const { rerender } = renderHook(
({ value }) => useAutoSave(value, save, { debounceMs: 300 }),
{ initialProps: { value: "v1" } },
);
rerender({ value: "v2" });
vi.advanceTimersByTime(100);
rerender({ value: "v3" });
vi.advanceTimersByTime(100);
rerender({ value: "v4" });
act(() => { vi.advanceTimersByTime(300); });
await act(() => Promise.resolve());
expect(save).toHaveBeenCalledTimes(1);
expect(save).toHaveBeenCalledWith("v4");
});
it("clears timers on unmount", () => {
const save = vi.fn().mockResolvedValue(undefined);
const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout");
const { rerender, unmount } = renderHook(
({ value }) => useAutoSave(value, save, { debounceMs: 500 }),
{ initialProps: { value: "v1" } },
);
rerender({ value: "v2" });
unmount();
expect(clearTimeoutSpy).toHaveBeenCalled();
clearTimeoutSpy.mockRestore();
});
});