refactor(frontend): the long tail — and eslint-suppressions.json is gone (E27 S2)
Closes the burn-down. Hook suppressions 113 -> 34, `exhaustive-deps` 45 -> 2 (the target was <10), and every one of the 34 survivors is an inline directive NAMING the invariant it protects. The suppressions file is deleted, because a bulk file hides the next violation instead of answering it — and it cannot hold a warning-level rule at all. The sprint plan's three verdicts missed the answer that turned out to be the most common one: adjust the state DURING RENDER. React re-runs the component and discards that pass rather than committing it, so the stale frame an effect-based reset paints does not exist. Applied to the Plex player's up-next card, the playlist view reset, the feed's optimistic overrides, the channel banner's retry count, the download center's tab guard, the Plex search highlight, the comfort-pin latch, Welcome's per-view outcome and the card pager's page clamp. It is legal only for a component's own state — PlexBrowse's language commit resets PlexProvider's filters, so that one stays an effect and says why. Mirrors of a prop or query became derived values or `draft ?? server`: - `downloadRow` had ui/Avatar's defect exactly — a boolean "poster failed" that an effect reset, in rows recycled down a virtualized list, so one job's missing poster painted over the next one's for a frame. Keyed by URL now. - Scheduler's two number editors held a mirror an effect re-synced, on a page that POLLS — the shape a poll can overwrite mid-edit. A draft while editing, the server's value otherwise; six tests where there were none, the one behavioural change (the save button stops being dirty at commit rather than at the echo) mutation-checked. - SetupWizard's "no token" and WatchPage's "gone" are facts about the URL, so they are seeded and derived rather than pushed into state by an effect that had to commit first. Elsewhere: PlexInfo's rating chip was a component declared during render — a new component type every render, so React remounted the subtree and dropped the focus on its filter button. Hoisted. Feed's undo action and PlexPlayer's session reclaim both re-entered their own useCallback, which the checker reads as a forward reference; both go through the ref idiom this file already uses. BootGate's dep list was load-bearing for correctness — a re-run meant `location.reload()` — so the "already adopted THIS identity" comparison now enforces it in code, with a test that fails without it (the existing reload tests all install their spy too late to catch it). One correction to the batch before this: keying ChatThread's and Messages' decrypt passes on `q.data` instead of `q.dataUpdatedAt` was wrong. react-query's structural sharing makes it safe in production, but the component must not melt down when something hands back a fresh object per render — and Messages.keyboard.dom.test does exactly that, so the effect span the suite. Both are back on the fetch as the trigger with the payload behind a ref, which is honest AND loop-proof; the replaced comment had warned about precisely this. CLAUDE.md gains four canonical-patterns rows for the above, and its "React 18" line is now true.
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
// @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");
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Scheduler · the maintenance batch editor", () => {
|
||||
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"));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user