Files
hermes-webui/tests/test_settings_appearance_defaults_honoured.py
c1fc3f2c79 Release exp-v0.52.178: honor configured appearance defaults for no-settings-file users (#6834) (#6849)
* fix(settings): _SETTINGS_DEFAULTS theme/skin never reach a user with no settings file

`_read_raw_settings_file()` returns `{}` for a MISSING settings.json. `{}` is a
dict, so `load_settings()` always took its `isinstance(stored, dict)` arms:

    settings["theme"], settings["skin"] = _normalize_appearance(
        stored.get("theme") if isinstance(stored, dict) else settings.get("theme"),
        stored.get("skin") if isinstance(stored, dict) else settings.get("skin"),
    )

`stored.get("theme")` is then None, `_normalize_appearance(None, None)` falls
through to its unknown-theme branch, and the result is ("dark", "default").
`_SETTINGS_DEFAULTS["theme"]` and `["skin"]` are therefore unreachable for the
one case they exist to serve: a user who has no settings file yet.

This is invisible on the shipped defaults, because dark/default is exactly what
the fallback produces — the two paths agree, so nothing ever caught it. It only
surfaces once the defaults are changed, at which point the dict silently does
nothing: a deployment (or fork, or a future change to the shipped values) sets an
appearance and every new user still gets dark.

The fix falls back to the defaults instead of to None, gated on the PAIR of
keys. Per-field (`or settings.get(...)`) looks equivalent and is not: with a
stored legacy theme and no stored skin, `slate` normalises to ("dark", "slate"),
but per-field fallback injects the default skin and returns ("dark", "default"),
silently dropping the legacy migration. That is the same absent-vs-explicit
distinction the boot script draws in #6808 — this is its server-side half. #6808
stops the browser fabricating a choice; without this, the server still answers
with the fallback rather than the configured default.

Behaviour on stock defaults is provably unchanged: `_normalize_appearance(None,
None)` and `_normalize_appearance("dark", "default")` both return
("dark", "default"). A stored preference is untouched in every case.

13 tests. Both directions are pinned, and both were checked by injection:
reverting api/config.py fails exactly the two "defaults are honoured" cases and
nothing else; substituting the per-field variant fails 8, including four of the
five legacy migrations. Ran the full settings/appearance surface — 61 test files,
873 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Release exp-v0.52.178: honor configured appearance defaults for users with no settings file (#6834)

---------

Co-authored-by: Hermes Agent <hermes@aip.de>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-08-07 23:06:57 -07:00

137 lines
5.5 KiB
Python

"""_SETTINGS_DEFAULTS theme/skin must survive load_settings() when nothing is stored.
``_read_raw_settings_file()`` returns ``{}`` for a MISSING settings.json, and ``{}``
is a dict — so ``load_settings()`` took its ``isinstance(stored, dict)`` arms, passed
``None`` into ``_normalize_appearance``, and got back that function's unknown-theme
fallback ``("dark", "default")``. The defaults dict was unreachable for the one case
it exists to serve: a user with no settings file yet.
On stock defaults this is invisible, because dark/default is exactly what the
fallback produces — the two paths agree, which is why nothing caught it. It only
surfaces once ``_SETTINGS_DEFAULTS`` is changed, at which point the dict silently
does nothing and the appearance a deployment configured never reaches the client.
The guard is on the PAIR of keys, not per field. A per-field
``or settings.get(...)`` looks equivalent and is not: with a stored legacy theme and
no stored skin, ``slate`` normalises to ``("dark", "slate")``, but per-field fallback
injects the default skin and yields ``("dark", "default")``, silently dropping the
legacy migration. The tests below pin that distinction in both directions.
"""
import json
import pytest
import api.config as config
@pytest.fixture()
def settings_file(tmp_path, monkeypatch):
"""Point config.SETTINGS_FILE at a temp path for isolated load_settings tests."""
f = tmp_path / "settings.json"
monkeypatch.setattr(config, "SETTINGS_FILE", f)
return f
@pytest.fixture()
def custom_defaults(monkeypatch):
"""Override ONLY theme/skin in the defaults, keeping every other key intact.
Values deliberately differ from the ("dark", "default") fallback, so a test
that passes here cannot be passing by coincidence — which is exactly how this
bug survived on stock defaults.
"""
d = dict(config._SETTINGS_DEFAULTS)
d["theme"] = "light"
d["skin"] = "poseidon"
monkeypatch.setattr(config, "_SETTINGS_DEFAULTS", d)
return d
def _write(f, payload):
f.write_text(json.dumps(payload), encoding="utf-8")
# ── the bug ───────────────────────────────────────────────────────────────
def test_defaults_are_used_when_no_settings_file_exists(settings_file, custom_defaults):
assert not settings_file.exists()
s = config.load_settings()
assert (s["theme"], s["skin"]) == ("light", "poseidon"), (
"with no settings.json, _SETTINGS_DEFAULTS theme/skin must be honoured; "
"falling through to _normalize_appearance(None, None) makes the defaults "
"dict unreachable for new users"
)
def test_defaults_are_used_when_file_has_no_appearance_keys(settings_file, custom_defaults):
_write(settings_file, {"font_size": "large"})
s = config.load_settings()
assert (s["theme"], s["skin"]) == ("light", "poseidon"), (
"a settings file that stores no appearance must still take the defaults"
)
# ── no behaviour change on stock defaults ─────────────────────────────────
def test_stock_defaults_are_unchanged_by_the_guard(settings_file):
"""On the shipped defaults this fix is a no-op — both paths give dark/default."""
assert not settings_file.exists()
s = config.load_settings()
assert (s["theme"], s["skin"]) == (
config._SETTINGS_DEFAULTS["theme"],
config._SETTINGS_DEFAULTS["skin"],
)
assert (s["theme"], s["skin"]) == ("dark", "default")
# ── a stored preference still wins, exactly as before ─────────────────────
@pytest.mark.parametrize(
"stored,expected",
[
({"theme": "dark"}, ("dark", "default")),
({"theme": "system"}, ("system", "default")),
({"theme": "light", "skin": "mono"}, ("light", "mono")),
({"skin": "mono"}, ("dark", "mono")),
({"theme": "not-a-theme"}, ("dark", "default")),
],
)
def test_stored_appearance_still_wins(settings_file, custom_defaults, stored, expected):
"""Either key present means the user has a preference; defaults must not leak in.
Note ``{"skin": "mono"}`` resolves the THEME to dark, not to the default light:
a stored skin makes the pair user-owned, so the whole pair goes through
normalisation unchanged. That is the pre-existing behaviour and must not move.
"""
_write(settings_file, stored)
s = config.load_settings()
assert (s["theme"], s["skin"]) == expected
# ── the reason the guard is pair-level ────────────────────────────────────
@pytest.mark.parametrize(
"legacy,expected",
[
("slate", ("dark", "slate")),
("solarized", ("dark", "poseidon")),
("monokai", ("dark", "sisyphus")),
("nord", ("dark", "slate")),
("oled", ("dark", "default")),
],
)
def test_legacy_theme_migration_is_not_broken_by_the_fallback(
settings_file, custom_defaults, legacy, expected
):
"""Only the theme is stored; the skin is DERIVED from the legacy mapping.
A per-field fallback would substitute the default skin here and destroy the
migration. With custom defaults of light/poseidon, that failure mode is
visible: `slate` would come back ("dark", "poseidon") instead of
("dark", "slate").
"""
_write(settings_file, {"theme": legacy})
s = config.load_settings()
assert (s["theme"], s["skin"]) == expected