fix(plex): address S3 review nits — tests + narrow-header wrap
- Add PlexScopeToggle.dom.test.tsx (renders the three scopes, current one pressed, click writes scope back to the provider) and usePlexActiveFilters.dom.test.tsx (emits filter + watch-state chips but never a sort chip, even with a non-default sort active) — the S3 relocation was untested. - Narrow-width header: make the header row flex-wrap so the scope toggle drops to a second line instead of crowding the flex-1 search box to zero width, and let the field-scope pill shrink/truncate. Desktop stays a single line (verified: 1280px header unchanged, search 237px, no wrap). The removed one-click sort-reset chip is intended (sort is an always-visible control now, like the Feed) — no change. FE suite 174 green; typecheck + eslint clean.
This commit is contained in:
@@ -94,7 +94,7 @@ export default function Header({
|
|||||||
// Absolutely positioned within the (relative) content column, so its left edge tracks the
|
// Absolutely positioned within the (relative) content column, so its left edge tracks the
|
||||||
// actual left zone: it sits just inside the content, reclaiming the space when the nav rail is
|
// actual left zone: it sits just inside the content, reclaiming the space when the nav rail is
|
||||||
// slim and/or no filter sidebar is present — no hard-coded offset.
|
// slim and/or no filter sidebar is present — no hard-coded offset.
|
||||||
<div className="pointer-events-none absolute top-3 left-3 right-[10%] z-chrome flex items-center gap-3">
|
<div className="pointer-events-none absolute top-3 left-3 right-[10%] z-chrome flex flex-wrap items-center gap-x-3 gap-y-2">
|
||||||
<ModuleName
|
<ModuleName
|
||||||
label={t(moduleLabelKey[page])}
|
label={t(moduleLabelKey[page])}
|
||||||
labels={moduleLabels}
|
labels={moduleLabels}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// Contract of the header library-scope toggle (S3 relocation): it renders the three scopes, marks
|
||||||
|
// the current one, and writes the picked scope back to the provider. i18n is key-passthrough and the
|
||||||
|
// provider is mocked so the test drives scope directly; SegmentedToggle is a plain button group (no
|
||||||
|
// floating-ui), so no observer stubs are needed.
|
||||||
|
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
|
||||||
|
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||||
|
|
||||||
|
vi.mock("react-i18next", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||||
|
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||||
|
});
|
||||||
|
|
||||||
|
const plexState = { scope: "both" } as { scope: string };
|
||||||
|
const setScope = vi.fn();
|
||||||
|
vi.mock("./PlexProvider", () => ({
|
||||||
|
usePlex: () => plexState,
|
||||||
|
usePlexActions: () => ({ setScope }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import PlexScopeToggle from "./PlexScopeToggle";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
plexState.scope = "both";
|
||||||
|
setScope.mockReset();
|
||||||
|
});
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
describe("PlexScopeToggle", () => {
|
||||||
|
it("renders the three library scopes, current one pressed", () => {
|
||||||
|
plexState.scope = "movie";
|
||||||
|
render(<PlexScopeToggle />);
|
||||||
|
const group = screen.getByRole("group", { name: "plex.filter.scope" });
|
||||||
|
const btns = [...group.querySelectorAll("button")];
|
||||||
|
expect(btns.map((b) => b.textContent)).toEqual([
|
||||||
|
"plex.filter.scopeOpt.both",
|
||||||
|
"plex.filter.scopeOpt.movie",
|
||||||
|
"plex.filter.scopeOpt.show",
|
||||||
|
]);
|
||||||
|
const pressed = btns.filter((b) => b.getAttribute("aria-pressed") === "true");
|
||||||
|
expect(pressed.map((b) => b.textContent)).toEqual(["plex.filter.scopeOpt.movie"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes the picked scope back to the provider", () => {
|
||||||
|
render(<PlexScopeToggle />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "plex.filter.scopeOpt.show" }));
|
||||||
|
expect(setScope).toHaveBeenCalledWith("show");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -117,10 +117,10 @@ export default function PlexSearchBox() {
|
|||||||
leading={
|
leading={
|
||||||
<ActionMenu
|
<ActionMenu
|
||||||
ariaLabel={t("plex.searchField.aria")}
|
ariaLabel={t("plex.searchField.aria")}
|
||||||
className="shrink-0 inline-flex items-center gap-1 rounded-lg px-2 h-7 text-xs font-medium text-muted transition hover:text-fg hover:bg-border/50"
|
className="min-w-0 shrink inline-flex items-center gap-1 rounded-lg px-2 h-7 text-xs font-medium text-muted transition hover:text-fg hover:bg-border/50"
|
||||||
trigger={
|
trigger={
|
||||||
<>
|
<>
|
||||||
<span className="max-w-28 truncate">{pillLabel}</span>
|
<span className="min-w-0 max-w-28 truncate">{pillLabel}</span>
|
||||||
<ChevronDown className="w-3 h-3 shrink-0" aria-hidden="true" />
|
<ChevronDown className="w-3 h-3 shrink-0" aria-hidden="true" />
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// Guards the S3 behavior change: the active-filter chip row (usePlexActiveFilters) still emits chips
|
||||||
|
// for the in-panel narrowings (expanded filters + watch-state) but NO LONGER emits a sort chip —
|
||||||
|
// sort is an always-visible control above the grid now, like the Feed. i18n is key-passthrough and
|
||||||
|
// the provider is mocked so the hook can be driven with a non-default sort present.
|
||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { renderHook } from "@testing-library/react";
|
||||||
|
import { EMPTY_PLEX_FILTERS } from "../lib/api";
|
||||||
|
|
||||||
|
vi.mock("react-i18next", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||||
|
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||||
|
});
|
||||||
|
|
||||||
|
const plexState = {
|
||||||
|
scope: "both",
|
||||||
|
show: "watched",
|
||||||
|
// A non-default sort is present in the shared state — the hook must IGNORE it (no sort chip).
|
||||||
|
sort: { key: "year", dir: "asc" },
|
||||||
|
filters: { ...EMPTY_PLEX_FILTERS, genres: ["Action"] },
|
||||||
|
};
|
||||||
|
const setShow = vi.fn();
|
||||||
|
const setFilters = vi.fn();
|
||||||
|
vi.mock("./PlexProvider", () => ({
|
||||||
|
usePlex: () => plexState,
|
||||||
|
usePlexActions: () => ({ setShow, setFilters }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { usePlexActiveFilters } from "./usePlexActiveFilters";
|
||||||
|
|
||||||
|
describe("usePlexActiveFilters", () => {
|
||||||
|
it("emits filter + watch-state chips but never a sort chip", () => {
|
||||||
|
const { result } = renderHook(() => usePlexActiveFilters());
|
||||||
|
const keys = result.current.map((c) => c.key);
|
||||||
|
// The in-panel narrowings still surface...
|
||||||
|
expect(keys).toContain("genre:Action");
|
||||||
|
expect(keys).toContain("show");
|
||||||
|
// ...but sort does not, even though a non-default sort is active in the shared state.
|
||||||
|
expect(keys).not.toContain("sort");
|
||||||
|
expect(result.current.every((c) => !c.key.startsWith("sort"))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user