- NavRailGroup: one "Admin" rail row opening a panel of the admin pages (links, so a middle-click still opens a tab); the rail fits 133% zoom without a hidden scroll - sysconfig.SECTIONS orders the config groups under four headings (every group in exactly one, test-held); the page shows them as Settings' side list (Tabs `group` headings); the lone registration group reads "Registration" under "Access" - StatTiles / StatTile / StatBars: the Statistics overview's figures as tiles, the per-operation quota use as horizontal bars (a zero draws none)
142 lines
5.6 KiB
TypeScript
142 lines
5.6 KiB
TypeScript
// @vitest-environment jsdom
|
|
//
|
|
// A closed-set config key is picked, not typed: a short list renders as a segmented bar, a long one as
|
|
// a dropdown, and the pick is what Save sends. A cleared draft (reset to the default) shows the default
|
|
// as the active option rather than no option at all.
|
|
//
|
|
// i18n is a key-passthrough, except for an option's label: a full key would distort the
|
|
// bar-or-dropdown choice, which weighs the real label length, so a choice id resolves to its value
|
|
// under a one-character marker. The marker is what makes a lost lookup visible — rendering the bare
|
|
// value instead of the id would be indistinguishable from `label: c`. The admin config API is stubbed.
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import type { ConfigItem } from "../lib/api";
|
|
|
|
vi.mock("react-i18next", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
|
return {
|
|
...actual,
|
|
useTranslation: () => ({
|
|
// Only an id the fixture really defines resolves: a dropped lookup renders the bare value and
|
|
// a malformed id renders the key (long enough to turn every bar into a dropdown), so the
|
|
// assertions below see both.
|
|
t: (key: string) => CHOICE_LABELS[key] ?? key,
|
|
i18n: { language: "en" },
|
|
}),
|
|
};
|
|
});
|
|
|
|
const choice = (key: string, choices: string[], value: string, is_set = false): ConfigItem => ({
|
|
key,
|
|
type: "choice",
|
|
group: "g",
|
|
secret: false,
|
|
min: null,
|
|
max: null,
|
|
is_set,
|
|
allow_empty: false,
|
|
choices,
|
|
value,
|
|
default: choices[0],
|
|
});
|
|
|
|
const PRESETS = ["ultrafast", "superfast", "veryfast", "faster", "fast", "medium", "slow"];
|
|
const LEVELS = ["TRACE", "DEBUG", "INFO", "WARNING", "ERROR"];
|
|
const CHOICE_LABELS: Record<string, string> = Object.fromEntries(
|
|
(
|
|
[
|
|
["plex_transcode_hw", ["auto", "on", "off"]],
|
|
["plex_transcode_preset", PRESETS],
|
|
["log_level", LEVELS],
|
|
] as const
|
|
).flatMap(([key, choices]) =>
|
|
choices.map((c) => [`config.choices.${key}.${c}`, `~${c}`] as const),
|
|
),
|
|
);
|
|
const setConfig = vi.fn((_key: string, _value: unknown) => Promise.resolve({}));
|
|
const resetConfig = vi.fn((_key: string) => Promise.resolve({}));
|
|
|
|
vi.mock("../lib/api", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("../lib/api")>();
|
|
return {
|
|
...actual,
|
|
api: {
|
|
...actual.api,
|
|
adminConfig: () =>
|
|
Promise.resolve({
|
|
groups: {
|
|
g: [
|
|
choice("plex_transcode_hw", ["auto", "on", "off"], "on", true),
|
|
choice("plex_transcode_preset", PRESETS, "veryfast"),
|
|
choice("log_level", LEVELS, "INFO"),
|
|
],
|
|
},
|
|
sections: [{ id: "s", groups: ["g"] }],
|
|
secrets_manageable: true,
|
|
}),
|
|
setConfig: (key: string, value: unknown) => setConfig(key, value),
|
|
resetConfig: (key: string) => resetConfig(key),
|
|
},
|
|
};
|
|
});
|
|
vi.mock("../lib/useMe", () => ({ useMe: () => ({ email_configured: true }) }));
|
|
|
|
import ConfigPanel from "./ConfigPanel";
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
const mount = () =>
|
|
render(
|
|
<QueryClientProvider client={new QueryClient()}>
|
|
<ConfigPanel />
|
|
</QueryClientProvider>,
|
|
);
|
|
|
|
describe("ConfigPanel choice keys", () => {
|
|
it("renders a short list as a segmented bar and saves the pick", async () => {
|
|
mount();
|
|
const bar = await screen.findByRole("group", { name: "config.fields.plex_transcode_hw.label" });
|
|
const pressed = () =>
|
|
[...bar.querySelectorAll('[aria-pressed="true"]')].map((b) => b.textContent);
|
|
expect(pressed()).toEqual(["~on"]);
|
|
expect(screen.queryByRole("textbox")).toBeNull(); // no free-text field for a choice key
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "~off" }));
|
|
fireEvent.click(await screen.findByRole("button", { name: "config.save" }));
|
|
await waitFor(() => expect(setConfig).toHaveBeenCalledWith("plex_transcode_hw", "off"));
|
|
});
|
|
|
|
it("shows the default as active once the override is reset, and Save resets it", async () => {
|
|
mount();
|
|
await screen.findByRole("group", { name: "config.fields.plex_transcode_hw.label" });
|
|
fireEvent.click(screen.getByRole("button", { name: /config\.reset$/ }));
|
|
expect(screen.getByRole("button", { name: "~auto" }).getAttribute("aria-pressed")).toBe("true");
|
|
fireEvent.click(await screen.findByRole("button", { name: "config.save" }));
|
|
await waitFor(() => expect(resetConfig).toHaveBeenCalledWith("plex_transcode_hw"));
|
|
});
|
|
|
|
it("renders a long list as a dropdown showing only the value", async () => {
|
|
mount();
|
|
const trigger = await screen.findByRole("button", {
|
|
name: "config.fields.plex_transcode_preset.label: ~veryfast",
|
|
});
|
|
expect(trigger.textContent).toBe("~veryfast");
|
|
fireEvent.click(trigger);
|
|
// The popover rows are queried as FilterSelect.dom.test does (`hidden: true`, picked by text).
|
|
const rows = screen.getAllByRole("menuitemradio", { hidden: true });
|
|
fireEvent.click(rows.find((r) => r.textContent === "~slow")!);
|
|
fireEvent.click(await screen.findByRole("button", { name: "config.save" }));
|
|
await waitFor(() => expect(setConfig).toHaveBeenCalledWith("plex_transcode_preset", "slow"));
|
|
});
|
|
|
|
it("a few options with long labels are a dropdown too: a bar that wide does not fit a phone", async () => {
|
|
mount();
|
|
await screen.findByRole("button", { name: "config.fields.log_level.label: ~INFO" });
|
|
expect(screen.queryByRole("group", { name: "config.fields.log_level.label" })).toBeNull();
|
|
});
|
|
});
|