feat: closed-set admin knobs are pickers, not free text
log_level, search_source, download_layout and the transcode hw/preset were typed by hand, and every consumer carried its own guess at what a typo meant. The value set is now a Literal in config.py: pydantic rejects a bad env value at boot (any letter case accepted), sysconfig refuses a bad override and derives the admin picker's options from the same Literal, and the consumers read the value as-is. The Configuration page renders a choice key as a segmented bar, or a dropdown past six options, with translated option names. Its tabs now follow the registry's order instead of a hand-kept copy that had already dropped the explore group. A single-choice FilterSelect now opens on the option in use: in the browser it could land on the first row instead.
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
// @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; 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: () => ({ t: (key: string) => 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 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"),
|
||||
],
|
||||
},
|
||||
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(["config.choices.plex_transcode_hw.on"]);
|
||||
expect(screen.queryByRole("textbox")).toBeNull(); // no free-text field for a choice key
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "config.choices.plex_transcode_hw.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: "config.choices.plex_transcode_hw.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: config.choices.plex_transcode_preset.veryfast",
|
||||
});
|
||||
expect(trigger.textContent).toBe("config.choices.plex_transcode_preset.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 === "config.choices.plex_transcode_preset.slow")!,
|
||||
);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "config.save" }));
|
||||
await waitFor(() => expect(setConfig).toHaveBeenCalledWith("plex_transcode_preset", "slow"));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user