The reset I added guards on `hidden === 0`, and `hidden` can be NEGATIVE. `useChipOverflowFit` keeps `visible` in state and re-decides in a layout effect, so the render in which the chip set SHRINKS still carries the previous measurement — delete three tags under an open "+N" and `chips.length - visible` goes to -2, the equality never matches, and the flag survives the one window the reset exists to close. The next re-fit brings the menu back on its own, which is the original defect with an extra step. Clamped at all three lanes, with a test that drives the shrink through the stale value. Also: the Scheduler characterization banner said "the four tests below" and now sits above six, the last two of which are genuine regression guards for the range-refusal fix. A future mutation check would have read it, seen them go red, and concluded the harness was broken rather than that the guard worked.
233 lines
9.4 KiB
TypeScript
233 lines
9.4 KiB
TypeScript
// @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<typeof import("react-i18next")>();
|
|
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> = {}): 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(
|
|
<QueryClientProvider client={qc}>
|
|
<Scheduler />
|
|
</QueryClientProvider>,
|
|
);
|
|
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"));
|
|
});
|
|
});
|