Files
siftlode/frontend/src/components/Scheduler.dom.test.tsx
T
peter d924c665f8 fix(frontend): round 2 — the popover latch reset read a stale, negative count
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.
2026-09-21 23:59:59 +02:00

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"));
});
});