// @vitest-environment jsdom // // The scheduler's two number editors — a job's interval and the maintenance batch size. Both used to // hold a MIRROR of the server value that an effect re-synced; both now show the server value and keep // a draft only while the field is being edited (E27 S2). That matters here more than elsewhere // because this page polls: every few seconds a fresh status arrives, and a mirror is exactly the // thing a poll can overwrite at the wrong moment. import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; import { render, screen, fireEvent, cleanup, act, waitFor } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, useTranslation: () => ({ t: (k: string) => k }) }; }); import Scheduler from "./Scheduler"; import { api, type SchedulerStatus } from "../lib/api"; import { qk } from "../lib/queryKeys"; const status = (over: Partial = {}): SchedulerStatus => ({ running_here: true, enabled: true, paused: false, jobs: [ { id: "sync", interval_minutes: 30, next_run: null, running: false, status: "ok", last_started: null, last_finished: null, last_result: null, last_error: null, progress: null, }, ], queue: { channels_recent_pending: 0, channels_deep_pending: 0, deep_eta_seconds: 0, videos_pending_enrich: 0, videos_pending_shorts: 0, videos_live_refresh: 0, }, quota: { used_today: 0, remaining_today: 100, daily_budget: 100, backfill_reserve: 0 }, maintenance: { revalidate_batch: 5000, revalidate_batch_default: 5000, min: 1000, max: 50000 }, ...over, }); let qc: QueryClient; let current: SchedulerStatus; async function mount(initial = status()) { current = initial; qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); render( , ); await screen.findByText("scheduler.maintenance.batchLabel"); } /** A poll landing, driven through the query the page actually makes. */ async function poll(next: SchedulerStatus) { current = next; await act(() => qc.refetchQueries({ queryKey: qk.scheduler() })); } beforeEach(() => { vi.spyOn(api, "schedulerStatus").mockImplementation(() => Promise.resolve(current)); }); afterEach(() => { cleanup(); vi.restoreAllMocks(); }); const intervalBox = () => screen.queryByRole("spinbutton", { name: "" }); const batchBox = () => screen.getByLabelText("scheduler.maintenance.batchLabel"); // The FIRST FOUR tests below are CHARACTERIZATION, not regression guards for E27 S2: the old // JobRow's re-sync effect was already guarded by `if (!editing)` and the mirror only rendered while // editing, so each of those four passes against the pre-sweep code too. They are here because the // editor had no tests at all. The two after them — the range refusal and the focus return — ARE // regression guards, for the silent-discard fix, and they fail against the code this file shipped // with. The editor that genuinely lost the user's typing to a poll is the maintenance batch box // below: its old effect had no guard, and that one has its own test. describe("Scheduler · the job interval editor", () => { it("opens on the current interval and commits a new one", async () => { const update = vi.spyOn(api, "updateSchedulerJob").mockResolvedValue(undefined as never); await mount(); fireEvent.click(screen.getByRole("button", { name: /scheduler.everyMin/ })); const box = intervalBox()!; expect(box).toHaveProperty("value", "30"); fireEvent.change(box, { target: { value: "45" } }); fireEvent.keyDown(box, { key: "Enter" }); expect(intervalBox()).toBeNull(); // committing closes the editor at once await waitFor(() => expect(update).toHaveBeenCalledWith("sync", 45)); }); it("drops what you typed on Escape and shows the server's value again", async () => { const update = vi.spyOn(api, "updateSchedulerJob").mockResolvedValue(undefined as never); await mount(); fireEvent.click(screen.getByRole("button", { name: /scheduler.everyMin/ })); fireEvent.change(intervalBox()!, { target: { value: "999" } }); fireEvent.keyDown(intervalBox()!, { key: "Escape" }); expect(update).not.toHaveBeenCalled(); expect(intervalBox()).toBeNull(); fireEvent.click(screen.getByRole("button", { name: /scheduler.everyMin/ })); expect(intervalBox()).toHaveProperty("value", "30"); }); it("keeps what you are typing when a poll lands underneath", async () => { await mount(); fireEvent.click(screen.getByRole("button", { name: /scheduler.everyMin/ })); fireEvent.change(intervalBox()!, { target: { value: "45" } }); await poll(status({ jobs: [{ ...status().jobs[0]!, interval_minutes: 30, running: true }] })); expect(intervalBox()).toHaveProperty("value", "45"); }); it("follows the server while the editor is closed", async () => { await mount(); await poll(status({ jobs: [{ ...status().jobs[0]!, interval_minutes: 90 }] })); await waitFor(() => expect(screen.getByRole("button", { name: /scheduler.everyMin/ })).toBeTruthy(), ); fireEvent.click(screen.getByRole("button", { name: /scheduler.everyMin/ })); expect(intervalBox()).toHaveProperty("value", "90"); }); it("refuses a value outside the range instead of silently dropping it", async () => { const update = vi.spyOn(api, "updateSchedulerJob").mockResolvedValue(undefined as never); await mount(); fireEvent.click(screen.getByRole("button", { name: /scheduler.everyMin/ })); fireEvent.change(intervalBox()!, { target: { value: "99999" } }); fireEvent.keyDown(intervalBox()!, { key: "Enter" }); expect(update).not.toHaveBeenCalled(); // Still open, still showing what was typed, and saying why. expect(intervalBox()).toHaveProperty("value", "99999"); expect(intervalBox()!.getAttribute("aria-invalid")).toBe("true"); expect(screen.getByRole("alert").textContent).toBe("scheduler.range"); }); it("hands focus back to the trigger when the editor closes", async () => { await mount(); const trigger = screen.getByRole("button", { name: /scheduler.everyMin/ }); fireEvent.click(trigger); expect(document.activeElement).toBe(intervalBox()); fireEvent.keyDown(intervalBox()!, { key: "Escape" }); await waitFor(() => expect(document.activeElement).toBe( screen.getByRole("button", { name: /scheduler.everyMin/ }), ), ); }); }); describe("Scheduler · the maintenance batch editor", () => { it("keeps what you are typing when a poll changes the batch underneath", async () => { // THE regression this editor needed: its pre-E27 effect re-synced on every // `revalidate_batch` change with no editing guard, so a poll landing mid-keystroke replaced // what the admin had typed. await mount(); fireEvent.change(batchBox(), { target: { value: "9000" } }); // The poll carries a new interval TOO, and the interval label follows the server unconditionally // — so waiting for it is how this test knows the new status has actually been rendered. Without // that anchor the assertion below passes on a page that simply has not polled yet. await poll( status({ jobs: [{ ...status().jobs[0]!, interval_minutes: 90 }], maintenance: { ...status().maintenance, revalidate_batch: 12000 }, }), ); await waitFor(() => expect(screen.getByRole("button", { name: /scheduler.everyMin/ })).toBeTruthy(), ); expect(batchBox()).toHaveProperty("value", "9000"); }); it("refuses a value outside min..max instead of silently dropping it", async () => { const save = vi.spyOn(api, "updateMaintenanceBatch").mockResolvedValue({ revalidate_batch: 5000, }); await mount(); fireEvent.change(batchBox(), { target: { value: "10" } }); // below m.min = 1000 fireEvent.keyDown(batchBox(), { key: "Enter" }); expect(save).not.toHaveBeenCalled(); expect(batchBox()).toHaveProperty("value", "10"); expect(batchBox().getAttribute("aria-invalid")).toBe("true"); expect(screen.getByRole("alert").textContent).toBe("scheduler.range"); expect(screen.getByRole("button", { name: "common.save" })).toHaveProperty("disabled", true); }); it("is not dirty until edited, and stops being dirty once saved", async () => { const save = vi .spyOn(api, "updateMaintenanceBatch") .mockResolvedValue({ revalidate_batch: 9000 }); await mount(); const saveBtn = () => screen.getByRole("button", { name: "common.save" }); expect(saveBtn()).toHaveProperty("disabled", true); fireEvent.change(batchBox(), { target: { value: "9000" } }); expect(saveBtn()).toHaveProperty("disabled", false); fireEvent.keyDown(batchBox(), { key: "Enter" }); expect(saveBtn()).toHaveProperty("disabled", true); // the draft is dropped at once await waitFor(() => expect(save).toHaveBeenCalledWith(9000)); }); it("follows a poll that changes the batch while nothing is being typed", async () => { await mount(); expect(batchBox()).toHaveProperty("value", "5000"); await poll(status({ maintenance: { ...status().maintenance, revalidate_batch: 12000 } })); await waitFor(() => expect(batchBox()).toHaveProperty("value", "12000")); }); });