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
241 lines
9.4 KiB
TypeScript
241 lines
9.4 KiB
TypeScript
// @vitest-environment jsdom
|
|
//
|
|
// S7 — the Personal-views manager gains an EDITABLE detail pane: captured filters are now removable
|
|
// chips that write the pruned set back to the stored view (undoable), an explicit Save-current button
|
|
// stores the live filters into the view, and rename has a visible commit affordance. This drives the
|
|
// real modal against a mocked savedViewsContext so we can assert exactly which mutation fires; the
|
|
// captured-filter row uses the REAL useActiveFilters (fed a tag from a seeded react-query cache), so
|
|
// the prune snapshots are the genuine ones the live chip row computes.
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import { render, screen, waitFor, cleanup, fireEvent } from "@testing-library/react";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
|
|
const h = vi.hoisted(() => ({
|
|
updateFilters: vi.fn(async (_id: number, _filters: Record<string, unknown>) => true),
|
|
saveCurrent: vi.fn(async (_id: number) => ({ saved: true }) as Record<string, unknown> | null),
|
|
rename: vi.fn(async (_id: number, _name: string) => true),
|
|
activeViewId: null as number | null,
|
|
}));
|
|
|
|
vi.mock("react-i18next", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
|
return {
|
|
...actual,
|
|
useTranslation: () => ({
|
|
i18n: { language: "en" },
|
|
t: (key: string, o?: { name?: string }) =>
|
|
key === "common.order.dragNamed" ? `${key}:${o?.name}` : key,
|
|
}),
|
|
};
|
|
});
|
|
|
|
// A full feed filter set that differs from the sidebar default ONLY in `tags`, so useActiveFilters
|
|
// surfaces exactly one chip (the tag) — a partial object would read missing show/sort/content as
|
|
// "active" and spray spurious chips.
|
|
const VIEW_FILTERS = {
|
|
tags: [1],
|
|
tagModes: { language: "or", topic: "or", other: "or" },
|
|
untagged: false,
|
|
channelIds: [],
|
|
sort: "newest",
|
|
includeNormal: true,
|
|
includeShorts: false,
|
|
includeLive: false,
|
|
show: "unwatched",
|
|
scope: "my",
|
|
q: "",
|
|
};
|
|
|
|
const CTX = {
|
|
source: "feed" as const,
|
|
views: [{ id: 5, name: "Docs", filters: VIEW_FILTERS, position: 0, is_default: false }],
|
|
loading: false,
|
|
displayView: null,
|
|
isDirty: false,
|
|
modalOpen: true,
|
|
openModal: vi.fn(),
|
|
closeModal: vi.fn(),
|
|
apply: vi.fn(),
|
|
clearActive: vi.fn(),
|
|
create: vi.fn(async () => true),
|
|
reorder: vi.fn(async () => {}),
|
|
remove: vi.fn(async () => true),
|
|
toggleDefault: vi.fn(async () => {}),
|
|
};
|
|
|
|
vi.mock("./savedViewsContext", () => ({
|
|
useSavedViews: () => ({
|
|
...CTX,
|
|
activeViewId: h.activeViewId,
|
|
updateFilters: h.updateFilters,
|
|
saveCurrent: h.saveCurrent,
|
|
rename: h.rename,
|
|
}),
|
|
// Passthrough coercion — the modal only needs tags/channelIds to be arrays before the chip builder.
|
|
asFilters: (raw: Record<string, unknown>) => ({
|
|
...raw,
|
|
tags: (raw.tags as unknown[]) ?? [],
|
|
channelIds: (raw.channelIds as unknown[]) ?? [],
|
|
}),
|
|
}));
|
|
|
|
import PersonalViewsModal from "./PersonalViewsModal";
|
|
import { api } from "../lib/api";
|
|
import { qk } from "../lib/queryKeys";
|
|
|
|
function mount() {
|
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
qc.setQueryData(qk.tags(), [{ id: 1, name: "Docu" }]);
|
|
vi.spyOn(api, "tags").mockResolvedValue([{ id: 1, name: "Docu" }] as never);
|
|
const utils = render(
|
|
<QueryClientProvider client={qc}>
|
|
<PersonalViewsModal />
|
|
</QueryClientProvider>,
|
|
);
|
|
return { ...utils, qc };
|
|
}
|
|
|
|
beforeEach(() => {
|
|
h.updateFilters.mockClear();
|
|
h.saveCurrent.mockClear();
|
|
h.rename.mockClear();
|
|
h.activeViewId = null;
|
|
// Fresh baseline each test — one test mutates CTX.views to simulate a refetch.
|
|
CTX.views = [{ id: 5, name: "Docs", filters: VIEW_FILTERS, position: 0, is_default: false }];
|
|
});
|
|
afterEach(() => {
|
|
cleanup();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe("PersonalViewsModal — S7 editable detail pane", () => {
|
|
it("removing a captured-filter chip writes the pruned filters back to the stored view", async () => {
|
|
mount();
|
|
const removeBtn = await screen.findByLabelText("feed.chips.remove");
|
|
fireEvent.click(removeBtn);
|
|
await waitFor(() => expect(h.updateFilters).toHaveBeenCalledTimes(1));
|
|
expect(h.updateFilters.mock.calls[0]![0]).toBe(5);
|
|
expect(h.updateFilters.mock.calls[0]![1]).toMatchObject({ tags: [] });
|
|
});
|
|
|
|
it("the prune is undoable — Undo re-persists the original filters", async () => {
|
|
mount();
|
|
fireEvent.click(await screen.findByLabelText("feed.chips.remove"));
|
|
await waitFor(() => expect(h.updateFilters).toHaveBeenCalledTimes(1));
|
|
// The undo control only appears once there is history.
|
|
const undo = await screen.findByLabelText("common.undo");
|
|
fireEvent.click(undo);
|
|
await waitFor(() => expect(h.updateFilters).toHaveBeenCalledTimes(2));
|
|
expect(h.updateFilters.mock.calls[1]![1]).toMatchObject({ tags: [1] });
|
|
});
|
|
|
|
it("a same-view refetch (new-identity filters) does not wipe the undo history", async () => {
|
|
// Guards the [selectedId]-only re-seed effect (its exhaustive-deps disable): after a chip prune the
|
|
// real provider refetches and hands back the SAME view id with a fresh-identity filters object. The
|
|
// draft must NOT re-seed on that (it would destroy the undo history and the Undo control). A deps
|
|
// regression that added selected.filters would make this fail.
|
|
const { rerender, qc } = mount();
|
|
fireEvent.click(await screen.findByLabelText("feed.chips.remove"));
|
|
await waitFor(() => expect(h.updateFilters).toHaveBeenCalledTimes(1));
|
|
expect(await screen.findByLabelText("common.undo")).toBeTruthy();
|
|
|
|
// Simulate the post-write refetch: same id 5, new object identity, carrying the pruned value.
|
|
CTX.views = [
|
|
{
|
|
id: 5,
|
|
name: "Docs",
|
|
filters: { ...VIEW_FILTERS, tags: [] },
|
|
position: 0,
|
|
is_default: false,
|
|
},
|
|
];
|
|
rerender(
|
|
<QueryClientProvider client={qc}>
|
|
<PersonalViewsModal />
|
|
</QueryClientProvider>,
|
|
);
|
|
|
|
// Undo history survived the refetch — the control is still there and still re-persists on click.
|
|
const undo = await screen.findByLabelText("common.undo");
|
|
fireEvent.click(undo);
|
|
await waitFor(() => expect(h.updateFilters).toHaveBeenCalledTimes(2));
|
|
expect(h.updateFilters.mock.calls[1]![1]).toMatchObject({ tags: [1] });
|
|
});
|
|
|
|
it("Save-current stores the live filters into the view when it isn't already the active one", async () => {
|
|
mount();
|
|
fireEvent.click(await screen.findByText("views.saveCurrent"));
|
|
await waitFor(() => expect(h.saveCurrent).toHaveBeenCalledWith(5));
|
|
});
|
|
|
|
it("Save-current is disabled when the selected view is already the active one", async () => {
|
|
h.activeViewId = 5;
|
|
mount();
|
|
const btn = (await screen.findByText("views.saveCurrent")).closest(
|
|
"button",
|
|
) as HTMLButtonElement;
|
|
expect(btn.disabled).toBe(true);
|
|
fireEvent.click(btn);
|
|
expect(h.saveCurrent).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rename commits via the visible check button once the name changes", async () => {
|
|
mount();
|
|
const input = await screen.findByDisplayValue("Docs");
|
|
// No change yet → the commit button is disabled.
|
|
const commit = screen.getByLabelText("views.rename").closest("button") as HTMLButtonElement;
|
|
expect(commit.disabled).toBe(true);
|
|
fireEvent.change(input, { target: { value: "Documentaries" } });
|
|
expect(commit.disabled).toBe(false);
|
|
fireEvent.click(commit);
|
|
await waitFor(() => expect(h.rename).toHaveBeenCalledWith(5, "Documentaries"));
|
|
});
|
|
});
|
|
|
|
describe("PersonalViewsModal — reordering without a drag (WCAG 2.5.7)", () => {
|
|
const view = (id: number, name: string, position: number) => ({
|
|
id,
|
|
name,
|
|
filters: VIEW_FILTERS,
|
|
position,
|
|
is_default: false,
|
|
});
|
|
|
|
it("moves the selected view with its order buttons, the moves that go nowhere held off", async () => {
|
|
CTX.reorder.mockClear();
|
|
CTX.views = [view(5, "Docs", 0), view(6, "Music", 1), view(7, "Tech", 2)];
|
|
mount();
|
|
const group = await screen.findByRole("group", { name: /common\.order\.label/ });
|
|
const btn = (name: string) =>
|
|
Array.from(group.querySelectorAll("button")).find(
|
|
(b) => b.getAttribute("aria-label") === name,
|
|
)!;
|
|
expect(btn("common.order.top").disabled).toBe(true);
|
|
expect(btn("common.order.up").disabled).toBe(true);
|
|
fireEvent.click(btn("common.order.down"));
|
|
expect(CTX.reorder).toHaveBeenLastCalledWith([6, 5, 7]);
|
|
fireEvent.click(btn("common.order.bottom"));
|
|
expect(CTX.reorder).toHaveBeenLastCalledWith([6, 7, 5]);
|
|
// A view picked in the list moves from ITS place, not the first one's.
|
|
fireEvent.click(screen.getByRole("button", { name: "Tech" }));
|
|
await waitFor(() => expect(btn("common.order.up").disabled).toBe(false));
|
|
fireEvent.click(btn("common.order.up"));
|
|
expect(CTX.reorder).toHaveBeenLastCalledWith([5, 7, 6]);
|
|
});
|
|
|
|
it("has no order buttons for a single view", async () => {
|
|
mount();
|
|
await screen.findByDisplayValue("Docs");
|
|
expect(screen.queryByRole("group", { name: /common\.order\.label/ })).toBeNull();
|
|
});
|
|
|
|
it("picks a view up from the keyboard, as its grip's instructions say", async () => {
|
|
CTX.views = [view(5, "Docs", 0), view(6, "Music", 1)];
|
|
mount();
|
|
const grip = await screen.findByRole("button", { name: "common.order.dragNamed:Docs" });
|
|
grip.focus();
|
|
fireEvent.keyDown(grip, { code: "Space", key: " " });
|
|
await waitFor(() => expect(document.body.textContent).toContain("common.dnd.pickedUp"));
|
|
});
|
|
});
|