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:
144
frontend/src/hooks/__tests__/useAutoSave.test.ts
Normal file
144
frontend/src/hooks/__tests__/useAutoSave.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user