Every locale formatter takes the language as an argument. Under the React
Compiler a formatter that read the i18n singleton was memoized on its other
arguments, so a card kept "2 hr 4 min" after a switch to Hungarian; plexLang()
had the same flaw and left Plex queries in the old language. Components now
take the language from useTranslation, and the signature makes forgetting it
a type error.
- ratings, file sizes, stream stats: the language's decimal mark ("8,3")
- view and subscriber counts compact in the language ("1,2 E")
- seconds as the language's unit ("6 mp") in the sliders and the stats
- a person's birth and death dates as dates, not ISO strings
- A-Z lists by the shown name in the language's alphabet (compareText),
the tag catalog re-sorted by its translated names
- lint local/locale-format (was date-format) also refuses Intl.NumberFormat
outside lib/format.ts, localeCompare, and a component reading the language
off the i18n singleton
284 lines
11 KiB
TypeScript
284 lines
11 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: () => ({ i18n: { language: "en" }, 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,
|
|
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/ }),
|
|
),
|
|
);
|
|
});
|
|
|
|
// E28 audit: a real Enter re-opened the editor — the keystroke's own activation landed on the trigger
|
|
// the focus had just returned to. The closing keys cancel their default action.
|
|
it("closing by a key does not press the trigger again", async () => {
|
|
await mount();
|
|
for (const key of ["Enter", "Escape"]) {
|
|
fireEvent.click(screen.getByRole("button", { name: /scheduler.everyMin/ }));
|
|
expect(fireEvent.keyDown(intervalBox()!, { key })).toBe(false);
|
|
}
|
|
});
|
|
});
|
|
|
|
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"));
|
|
});
|
|
});
|
|
|
|
describe("Scheduler while paused (E28 audit F13)", () => {
|
|
// Unavailable WITH a reason ("resume first"): the runs stay in the tab order, so a keyboard user
|
|
// reaches the hint, and they are inert. Natively disabled, the reason was mouse-only.
|
|
it("keeps Run all and a job's Run focusable and inert", async () => {
|
|
const runAll = vi.spyOn(api, "runAllSchedulerJobs").mockResolvedValue({ started: [] });
|
|
const runOne = vi.spyOn(api, "runSchedulerJob").mockResolvedValue({ started: true } as never);
|
|
await mount(status({ paused: true }));
|
|
for (const name of ["scheduler.runAll", "scheduler.runNow"]) {
|
|
const btn = screen.getByRole("button", { name });
|
|
expect(btn.hasAttribute("disabled")).toBe(false);
|
|
expect(btn.getAttribute("aria-disabled")).toBe("true");
|
|
fireEvent.click(btn);
|
|
}
|
|
expect(runAll).not.toHaveBeenCalled();
|
|
expect(runOne).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("a job's last result (E30)", () => {
|
|
it("is named from its data, not the server's English one-liner", async () => {
|
|
const base = status();
|
|
await mount({
|
|
...base,
|
|
jobs: [
|
|
{
|
|
...base.jobs[0]!,
|
|
last_finished: new Date().toISOString(),
|
|
last_result: "new=3, failed=0",
|
|
result: { new: 3, failed: 0 },
|
|
},
|
|
],
|
|
});
|
|
// Key-echo i18n: the keys named, the counts shown.
|
|
expect(
|
|
await screen.findByText(/scheduler\.result\.new: 3 · scheduler\.result\.failed: 0/),
|
|
).toBeTruthy();
|
|
expect(screen.queryByText(/new=3/)).toBeNull();
|
|
});
|
|
});
|