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,93 @@
|
||||
"""The sysconfig registry: its order is the admin page's tab order, and a closed-set knob is a CHOICE.
|
||||
|
||||
The admin Configuration page used to keep its own hand-written group order, which had already drifted
|
||||
(`explore` was missing, so its tab sorted last). The page now follows SPECS, so SPECS has to read in
|
||||
tab order: one run of keys per group.
|
||||
|
||||
A closed-set admin knob is a CHOICE, not free text (E22 S2 follow-up).
|
||||
|
||||
`log_level`, `search_source`, `download_layout` and the transcode `hw`/`preset` used to be free-text
|
||||
fields: the admin could type anything and every consumer carried its own `or "auto").lower()` guess
|
||||
at what a typo meant. Now the value set is a `Literal` in config.py, pydantic holds the env to it at
|
||||
boot, sysconfig holds a stored override to it, and the admin page renders a picker from the same list.
|
||||
The DB lane (`db` fixture) covers the store; the rest is pure logic.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app import sysconfig
|
||||
from app.config import Settings, choices_of
|
||||
|
||||
CHOICE_KEYS = {s.key for s in sysconfig.SPECS if s.type == "choice"}
|
||||
|
||||
|
||||
def test_each_group_is_one_run_of_keys_so_the_registry_reads_in_tab_order():
|
||||
runs = [
|
||||
s.group
|
||||
for i, s in enumerate(sysconfig.SPECS)
|
||||
if i == 0 or sysconfig.SPECS[i - 1].group != s.group
|
||||
]
|
||||
assert len(runs) == len(set(runs)), runs
|
||||
assert runs[:2] == ["access", "email"]
|
||||
|
||||
|
||||
def test_every_closed_set_knob_is_a_choice_whose_options_come_from_config():
|
||||
assert CHOICE_KEYS == {
|
||||
"search_source",
|
||||
"download_layout",
|
||||
"log_level",
|
||||
"plex_transcode_hw",
|
||||
"plex_transcode_preset",
|
||||
}
|
||||
for s in sysconfig.SPECS:
|
||||
field = Settings.model_fields[s.default_attr]
|
||||
if s.type == "choice":
|
||||
assert s.choices, s.key
|
||||
assert field.default in s.choices, s.key
|
||||
else:
|
||||
# A Literal-typed setting exposed as "str" would be a free-text field again.
|
||||
assert s.choices == () and choices_of(field.annotation) == (), s.key
|
||||
|
||||
|
||||
def test_the_env_accepts_any_letter_case_and_rejects_an_unknown_value():
|
||||
assert Settings(log_level="debug").log_level == "DEBUG"
|
||||
assert Settings(search_source=" API ").search_source == "api"
|
||||
with pytest.raises(ValidationError):
|
||||
Settings(log_level="verbose")
|
||||
|
||||
|
||||
class _Row:
|
||||
def __init__(self, value):
|
||||
self.value, self.encrypted = value, False
|
||||
|
||||
|
||||
class _FakeDb:
|
||||
"""Just enough of a Session for sysconfig's reads: one stored override row per key."""
|
||||
|
||||
def __init__(self, **rows):
|
||||
self.rows = {k: _Row(v) for k, v in rows.items()}
|
||||
|
||||
def get(self, _model, key):
|
||||
return self.rows.get(key)
|
||||
|
||||
|
||||
def test_a_stored_value_reads_back_canonical_and_a_stale_one_falls_back_to_the_default():
|
||||
assert sysconfig.get_str(_FakeDb(plex_transcode_hw="OFF"), "plex_transcode_hw") == "off"
|
||||
# A value that is no longer (or never was) a choice is not passed through to the consumer.
|
||||
assert sysconfig.get_str(_FakeDb(plex_transcode_hw="maybe"), "plex_transcode_hw") == "auto"
|
||||
|
||||
|
||||
def test_describe_hands_the_picker_its_options():
|
||||
items = {i["key"]: i for g in sysconfig.describe(_FakeDb())["groups"].values() for i in g}
|
||||
assert items["log_level"]["choices"] == ["TRACE", "DEBUG", "INFO", "WARNING", "ERROR"]
|
||||
assert items["log_level"]["value"] == "INFO"
|
||||
assert items["quota_daily_budget"]["choices"] == []
|
||||
|
||||
|
||||
def test_set_value_stores_the_canonical_spelling_and_refuses_anything_else(db):
|
||||
sysconfig.set_value(db, "search_source", "API")
|
||||
assert sysconfig.get_str(db, "search_source") == "api"
|
||||
with pytest.raises(ValueError, match="scrape, api"):
|
||||
sysconfig.set_value(db, "search_source", "bing")
|
||||
db.rollback()
|
||||
Reference in New Issue
Block a user