Files
siftlode/frontend/src/components/PriorityStepper.dom.test.tsx
T
peter f80ed115ad fix(plex,channels): deep-review fixes — durable watch-sync + no shared-link clobber
Deep-review of the quickfix batch surfaced two data/preference-loss bugs plus
smaller items; all fixed with tests.

- watch_sync _pull_apply: don't let the Plex-authoritative pull overwrite a DIRTY
  local row (a change whose immediate push failed) — that stranded the value
  forever since _repush_dirty only retries dirty rows. Leave it dirty for re-push.
- PlexProvider / ChannelsProvider: a partial shared link opened in a fresh tab
  persisted mount-time DEFAULTS over the account's stored scope/sort/filters
  (base read before the account was pinned). Persist/keep only URL-present dims;
  re-hydrate the omitted ones from storage.
- playback resume write-back: guard against out-of-order delivery (a superseded
  older push now drops) and evict the throttle map so it can't grow unbounded.
- PlexPlayer: announce mid-playback buffering to assistive tech (role=status +
  sr-only label); new plex.player.buffering (HU+EN).
- columnSort: hoist the shared key:dir sort codec used by both URL-state modules.
- Tests: dirty-guard + flag-settle + per-key throttle scoping + superseded-push
  (backend); hold-repeat/suppress-click/unmount + partial-link no-clobber +
  refetch-doesn't-clobber-undo (frontend). Backend pytest 475, FE vitest 519.
2026-08-27 23:54:44 +02:00

138 lines
5.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @vitest-environment jsdom
//
// Interaction contract of the shared PriorityStepper (channel manager cell + detail header). i18n is
// mocked to a key-passthrough so aria-label lookups read stable keys, not translations. The visual
// size/border split (`sm` vs `md`) is CSS-only, so we assert behaviour, not styling.
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { PriorityStepper } from "./PriorityStepper";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
});
afterEach(() => {
cleanup();
vi.useRealTimers(); // any fake-timer test resets here so it can't leak into the next
});
const raise = () => screen.getByRole("button", { name: "channels.row.raisePriority" });
const lower = () => screen.getByRole("button", { name: "channels.row.lowerPriority" });
const valueBtn = () => screen.getByRole("button", { name: "channels.row.editPriority" });
describe("PriorityStepper", () => {
it("shows the current priority and both step buttons", () => {
render(<PriorityStepper priority={12} onPriority={vi.fn()} onSet={vi.fn()} />);
expect(valueBtn().textContent).toBe("12");
expect(raise()).toBeTruthy();
expect(lower()).toBeTruthy();
});
it("tapping + / − nudges by ±1", () => {
const onPriority = vi.fn();
render(<PriorityStepper priority={0} onPriority={onPriority} onSet={vi.fn()} />);
fireEvent.click(raise());
fireEvent.click(lower());
expect(onPriority.mock.calls).toEqual([[1], [-1]]);
});
it("clicking the value opens an inline editor seeded with the current value", () => {
render(<PriorityStepper priority={7} onPriority={vi.fn()} onSet={vi.fn()} />);
fireEvent.click(valueBtn());
const input = screen.getByRole("spinbutton") as HTMLInputElement;
expect(input.value).toBe("7");
});
it("typing a new value and pressing Enter commits it via onSet (negatives allowed)", () => {
const onSet = vi.fn();
render(<PriorityStepper priority={7} onPriority={vi.fn()} onSet={onSet} />);
fireEvent.click(valueBtn());
const input = screen.getByRole("spinbutton");
fireEvent.change(input, { target: { value: "-3" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(onSet).toHaveBeenCalledWith(-3);
});
it("Escape cancels the edit without committing", () => {
const onSet = vi.fn();
render(<PriorityStepper priority={7} onPriority={vi.fn()} onSet={onSet} />);
fireEvent.click(valueBtn());
const input = screen.getByRole("spinbutton");
fireEvent.change(input, { target: { value: "99" } });
fireEvent.keyDown(input, { key: "Escape" });
expect(onSet).not.toHaveBeenCalled();
// editor closed → the value button is back
expect(valueBtn().textContent).toBe("7");
});
it("committing an unchanged value does not call onSet", () => {
const onSet = vi.fn();
render(<PriorityStepper priority={7} onPriority={vi.fn()} onSet={onSet} />);
fireEvent.click(valueBtn());
fireEvent.keyDown(screen.getByRole("spinbutton"), { key: "Enter" });
expect(onSet).not.toHaveBeenCalled();
});
it("renders the same interactive contract at size=md", () => {
const onPriority = vi.fn();
render(<PriorityStepper priority={3} onPriority={onPriority} onSet={vi.fn()} size="md" />);
expect(valueBtn().textContent).toBe("3");
fireEvent.click(raise());
expect(onPriority).toHaveBeenCalledWith(1);
});
it("holding a step button auto-repeats (accelerating) and suppresses the trailing click", () => {
vi.useFakeTimers();
const onPriority = vi.fn();
render(<PriorityStepper priority={0} onPriority={onPriority} onSet={vi.fn()} />);
fireEvent.pointerDown(raise());
vi.advanceTimersByTime(349);
expect(onPriority).not.toHaveBeenCalled(); // a tap below the 350ms threshold doesn't repeat
vi.advanceTimersByTime(1); // cross the threshold → first auto-repeat tick
expect(onPriority).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(1000); // several accelerating repeats while held
const heldCount = onPriority.mock.calls.length;
expect(heldCount).toBeGreaterThan(3);
expect(onPriority.mock.calls.every(([d]) => d === 1)).toBe(true);
fireEvent.pointerUp(raise());
vi.advanceTimersByTime(1000);
expect(onPriority.mock.calls.length).toBe(heldCount); // stops repeating on release
fireEvent.click(raise()); // the click that trails a hold is swallowed (no double-count)
expect(onPriority.mock.calls.length).toBe(heldCount);
});
it("a quick tap (released before the hold threshold) still nudges exactly once", () => {
vi.useFakeTimers();
const onPriority = vi.fn();
render(<PriorityStepper priority={0} onPriority={onPriority} onSet={vi.fn()} />);
fireEvent.pointerDown(lower());
vi.advanceTimersByTime(100); // below the threshold → no auto-repeat armed
fireEvent.pointerUp(lower());
fireEvent.click(lower()); // the tap's click is NOT suppressed
expect(onPriority.mock.calls).toEqual([[-1]]);
});
it("clears the auto-repeat timer on unmount (no fire after the row is gone)", () => {
vi.useFakeTimers();
const onPriority = vi.fn();
const { unmount } = render(
<PriorityStepper priority={0} onPriority={onPriority} onSet={vi.fn()} />,
);
fireEvent.pointerDown(raise());
vi.advanceTimersByTime(350); // repeat armed + first tick
const before = onPriority.mock.calls.length;
expect(before).toBeGreaterThan(0);
unmount();
vi.advanceTimersByTime(1000);
expect(onPriority.mock.calls.length).toBe(before); // no post-unmount timer fire
});
});