phase: 92_theme_save_and_coverage
**Phase 92 final verification pass — all green.** This pass re-verified the completed tasks (all 5 task files already in `complete/`) against every completion criterion; no defects found, nothing to fix. - Verified: 9th identity var `grid_line` end-to-end (migration `0015` at head, model/`theming.py`/schemas/API, 422 + built-in→NULL tests present); `styles.css` zero hardcoded literals outside `:root` + derived `--brand-*` vars; 9th picker in theme form; wordmark themed; `theme.js` save/reset/re-show/mount live-sync; dedicated E2E suite + phase-91 suite updated. - `uv run pytest --cov=app --cov-report=term-missing` → **1845 passed, exit 0, TOTAL 99%** (>90%) - `uv run ruff check .` → clean; `uv run pyright` → 0 errors, 0 warnings - `uv run pytest tests/e2e/test_theme_save_and_coverage.py -v --no-cov` → **3 passed** (save-live, reset-live, whole-site) - `uv run pytest tests/e2e/test_admin_theme_tab.py -v --no-cov` → **5 passed** - Criteria: (1) Save/Reset repaint open page, no nav, SPA-nav survives, pre-paint intact ✅; (2) both `rg` gates green (only `:root` + documented `#fff` Stop label; zero SVG hex attrs), grid/selection/hovers/wash/wordmark E2E-proven ✅; (3) no-op contract live-checked: row-less `/` = no tag + exact A1 CSP, grid-only row = 9-var tag in `COLOR_FIELDS` order + sha256 CSP, with-row ≡ row-less bytes ✅; (4) full suite/coverage/lint/both E2E ✅; (5) commit left to the harness per instructions. - Deviations (previously made, probe-verified, kept): live repaint uses CSSOM `<html>` overrides because Chromium blocks `<style>` textContent mutations under the locked sha256-only CSP (tag text still mirrors the next load; `<html>` style exact-saved after Save, empty after Reset); wordmark themed via 3 `.brand-mark` CSS rules instead of inline styles (task 03's inline attrs were CSP-blocked — fixed during task 04). - Next pending phase: none — `todo/` contains only `92_theme_save_and_coverage`.
This commit is contained in:
@@ -108,17 +108,21 @@ def test_no_background_layer_declares_animation() -> None:
|
||||
|
||||
def test_grid_layer_is_static_and_unchanged() -> None:
|
||||
"""The owner removed the animated part, not the grid: body::before
|
||||
keeps 44px cells, 1px lines at the fixed 60% line alpha (warm
|
||||
rebrand tone), and the widened radial mask (both the -webkit- and
|
||||
standard mask properties) — and carries NO animation."""
|
||||
keeps 44px cells, 1px lines at 60% of the grid line color, and the
|
||||
widened radial mask (both the -webkit- and standard mask
|
||||
properties) — and carries NO animation. Phase 92 (task 02): the
|
||||
line color is the 9th identity variable --grid-line at 60% (the
|
||||
built-in #4a2626 reproduces the old warm tone exactly — and the
|
||||
tab's Grid lines picker now repaints this texture)."""
|
||||
block = _rule_block(_css(), GRID_LAYER)
|
||||
grid = "color-mix(in srgb, var(--grid-line) 60%, transparent)"
|
||||
assert "background-size: 44px 44px" in block
|
||||
assert (
|
||||
"linear-gradient(to right, rgb(74 38 38 / 0.6) 1px, transparent 1px)" in block
|
||||
), "grid must keep horizontal 1px lines at 60% line alpha"
|
||||
assert (
|
||||
"linear-gradient(to bottom, rgb(74 38 38 / 0.6) 1px, transparent 1px)" in block
|
||||
), "grid must keep vertical 1px lines at 60% line alpha"
|
||||
assert f"linear-gradient(to right, {grid} 1px, transparent 1px)" in block, (
|
||||
"grid must keep horizontal 1px lines at 60% --grid-line"
|
||||
)
|
||||
assert f"linear-gradient(to bottom, {grid} 1px, transparent 1px)" in block, (
|
||||
"grid must keep vertical 1px lines at 60% --grid-line"
|
||||
)
|
||||
mask = "radial-gradient(140% 110% at 50% 0%, black 40%, transparent 90%)"
|
||||
assert f"-webkit-mask-image: {mask};" in block
|
||||
assert f"mask-image: {mask};" in block
|
||||
|
||||
@@ -796,7 +796,7 @@ def test_middleware_themed_injects_tag_before_head_on_every_page(db: Session) ->
|
||||
``/``, the non-shell ``/document.html``, and the dynamic
|
||||
``/shared/<token>`` (the prefix branch) — carries EXACTLY ONE
|
||||
``<style id="bor-theme">`` IMMEDIATELY before ``</head>`` (a leading
|
||||
newline, nothing between), with all 8 ``--*`` vars in ``COLOR_FIELDS``
|
||||
newline, nothing between), with all 9 ``--*`` vars in ``COLOR_FIELDS``
|
||||
order and the changed value; the ``?v=`` asset rewrite still applies
|
||||
alongside."""
|
||||
db.execute(text("DELETE FROM ui_settings"))
|
||||
@@ -819,7 +819,7 @@ def test_middleware_themed_injects_tag_before_head_on_every_page(db: Session) ->
|
||||
# (nothing between the tag and the close).
|
||||
assert "\n" + tag + "</head>" in r.text
|
||||
assert r.text.index(tag) == r.text.index("</head>") - len(tag)
|
||||
# All 8 vars, COLOR_FIELDS order, the changed value present.
|
||||
# All 9 vars, COLOR_FIELDS order, the changed value present.
|
||||
declared = re.search(r'<style id="bor-theme">:root\{([^}]*)\}</style>', r.text)
|
||||
assert declared is not None
|
||||
names = re.findall(r"--([a-z-]+):", declared.group(1))
|
||||
@@ -842,6 +842,40 @@ def test_middleware_themed_injects_tag_before_head_on_every_page(db: Session) ->
|
||||
db.commit()
|
||||
|
||||
|
||||
def test_middleware_grid_only_change_tag_carries_grid_line(db: Session) -> None:
|
||||
"""Phase 92 (task 01): the 9th identity variable — the OTHER 8 colors
|
||||
at built-in + ONLY ``grid_line`` set still breaks the no-op contract:
|
||||
the tag is NON-empty and carries ALL 9 vars (``--grid-line:`` with
|
||||
the changed value, the rest their built-ins, ``COLOR_FIELDS`` order)
|
||||
with the matching style-src CSP hash."""
|
||||
db.execute(text("DELETE FROM ui_settings"))
|
||||
db.add(UiSettings(id=1, grid_line="#123123"))
|
||||
db.commit()
|
||||
try:
|
||||
colors = dict(theming.BUILTIN_COLORS)
|
||||
colors["grid_line"] = "#123123" # one changed color, rest built-in
|
||||
tag = theming.theme_style_tag(colors)
|
||||
assert tag != "" # the no-op contract holds ONLY for all-built-in
|
||||
assert "--grid-line:#123123;" in tag
|
||||
client = TestClient(_theme_page_app())
|
||||
r = client.get("/")
|
||||
assert r.status_code == 200
|
||||
assert r.text.count('id="bor-theme"') == 1
|
||||
assert "\n" + tag + "</head>" in r.text
|
||||
# All 9 vars, COLOR_FIELDS order (grid_line between line and brand).
|
||||
declared = re.search(r'<style id="bor-theme">:root\{([^}]*)\}</style>', r.text)
|
||||
assert declared is not None
|
||||
names = re.findall(r"--([a-z-]+):", declared.group(1))
|
||||
assert names == [k.replace("_", "-") for k in theming.COLOR_FIELDS]
|
||||
assert names.index("grid-line") == 5
|
||||
assert r.headers["content-security-policy"] == (
|
||||
f"{CSP}; style-src 'self' '{theming.theme_csp_hash(tag)}'"
|
||||
)
|
||||
finally:
|
||||
db.execute(text("DELETE FROM ui_settings"))
|
||||
db.commit()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("what",),
|
||||
[("session",), ("resolver",)],
|
||||
|
||||
@@ -245,7 +245,9 @@ def test_new_chat_button_style_contract() -> None:
|
||||
assert "background: var(--brand)" in body, "solid brand fill (the rebrand)"
|
||||
assert "color: var(--bg)" in body, "--bg text on --brand = 5.2:1 (AA)"
|
||||
hover = re.search(r"\.new-chat-btn:hover \{([\s\S]*?)\n\}", css)
|
||||
assert hover and "#f55a72" in hover.group(1), "hover lightens the brand fill"
|
||||
assert hover and "var(--brand-hover)" in hover.group(1), (
|
||||
"hover lightens the brand fill (phase 92: --brand-hover)"
|
||||
)
|
||||
# Mobile (≤640px): the button sits in .chat-shell, not the navbar —
|
||||
# the label stays visible and the plus icon is hidden (room in the
|
||||
# body); the pill stays ≥44px via min-height.
|
||||
|
||||
@@ -97,15 +97,19 @@ def test_reduced_motion_calm_not_removed() -> None:
|
||||
def test_busy_button_style_tokens() -> None:
|
||||
"""Phase 48 (revised contract, owner-locked 2026-08-29): in flight
|
||||
the button is the enabled Stop control — "Stop" label, .is-stop
|
||||
class (rose treatment, 6.3:1 with the #fff label), spinner hidden;
|
||||
idle/error keep the brand Send button (dark ink on brand 5.2:1).
|
||||
class (--brand-stop treatment — the brand darkened toward --bg,
|
||||
5.8:1 with the white label at the built-in default, phase 92),
|
||||
spinner hidden; idle/error keep the brand Send button
|
||||
(dark ink on brand 5.2:1).
|
||||
The spinner element stays in the markup + CSS (16px dark arc — the
|
||||
reduced-motion pin below) but the state machine never shows it: the
|
||||
Stop label + treatment carry the in-flight state."""
|
||||
css = _css()
|
||||
js = _js()
|
||||
assert ".send-btn.is-stop" in css
|
||||
assert "#be123c" in css, "the stop background: rose-700 (6.3:1 with #fff)"
|
||||
assert "background: var(--brand-stop)" in css, (
|
||||
"the stop background: the brand darkened toward --bg (5.8:1 with the white label)"
|
||||
)
|
||||
assert ".send-btn.is-stop:hover" in css, "the darker hover step"
|
||||
assert re.search(r"\.spinner \{[^}]*width: 16px", css)
|
||||
assert 'sendLabel.textContent = inFlight ? "Stop" : "Send"' in js
|
||||
|
||||
@@ -759,7 +759,7 @@ def test_history_refresh_button_css_reuses_the_new_chat_language() -> None:
|
||||
assert "color: var(--bg)" in body, "--bg text on --brand (5.2:1, AA)"
|
||||
assert "min-height: 44px" in body, "the comfortable touch target"
|
||||
assert "border-radius: 999px" in body and "border: 0" in body, "the pill"
|
||||
assert ".history-refresh:hover { background: #f55a72; color: var(--bg); }" in css
|
||||
assert ".history-refresh:hover { background: var(--brand-hover); color: var(--bg); }" in css
|
||||
assert ".history-refresh:disabled { opacity: 0.6; cursor: wait; }" in css, (
|
||||
"the in-flight disabled state is dimmed (the house language)"
|
||||
)
|
||||
@@ -902,8 +902,8 @@ def test_theme_view_scaffold_in_the_shell() -> None:
|
||||
the ship-hidden #theme-content (the #git-sources-content pattern)
|
||||
holding the STATIC form skeleton: the page-head (h1 "Theme"), the
|
||||
#theme-form with the 3 labeled branding text inputs (maxlength=300
|
||||
— the server re-validates) + the 8 labeled type=color palette inputs
|
||||
(the 8 identity variables, in the theming.COLOR_FIELDS order), the
|
||||
— the server re-validates) + the 9 labeled type=color palette inputs
|
||||
(the 9 identity variables, in the theming.COLOR_FIELDS order), the
|
||||
#theme-save (primary) + #theme-reset (secondary) — BOTH type="button"
|
||||
(no real submit), and the three task-05 feedback lines: #theme-error
|
||||
(role=alert), #theme-result (role=status), #theme-contrast
|
||||
@@ -941,7 +941,7 @@ def test_theme_view_scaffold_in_the_shell() -> None:
|
||||
)
|
||||
# The static form skeleton (the E2E-stable-selectors house
|
||||
# convention): the 3 labeled branding text inputs (maxlength=300)
|
||||
# and the 8 labeled type=color palette inputs (the 8 identity
|
||||
# and the 9 labeled type=color palette inputs (the 9 identity
|
||||
# variables — one per theming.COLOR_FIELDS field).
|
||||
assert re.search(r'<form[^>]*id="theme-form"[^>]*>', body), (
|
||||
"the #theme-form must be STATIC markup in the shell"
|
||||
@@ -959,6 +959,7 @@ def test_theme_view_scaffold_in_the_shell() -> None:
|
||||
"theme-ink",
|
||||
"theme-ink-soft",
|
||||
"theme-line",
|
||||
"theme-grid-line",
|
||||
"theme-brand",
|
||||
"theme-brand-soft",
|
||||
"theme-brand-ink",
|
||||
|
||||
@@ -22,15 +22,16 @@ def test_all_tables_registered() -> None:
|
||||
|
||||
|
||||
def test_ui_settings_single_row_nullable_contract() -> None:
|
||||
"""Phase 91: the single-row UI settings table — Integer PK ``id``
|
||||
with the Python-side ``default=1`` (the row is always id 1), the 3
|
||||
strings VARCHAR(300) and the 8 identity colors VARCHAR(7), ALL
|
||||
nullable (NULL = default — B1: env value for the strings, the
|
||||
built-in palette for the colors)."""
|
||||
"""Phase 91 (9 identity colors after phase 92, task 01): the
|
||||
single-row UI settings table — Integer PK ``id`` with the
|
||||
Python-side ``default=1`` (the row is always id 1), the 3 strings
|
||||
VARCHAR(300) and the 9 identity colors VARCHAR(7), ALL nullable
|
||||
(NULL = default — B1: env value for the strings, the built-in
|
||||
palette for the colors)."""
|
||||
settings_table = Base.metadata.tables["ui_settings"]
|
||||
assert set(settings_table.c.keys()) == {
|
||||
"id", "app_name", "input_placeholder", "footer_text",
|
||||
"bg", "surface", "ink", "ink_soft", "line",
|
||||
"bg", "surface", "ink", "ink_soft", "line", "grid_line",
|
||||
"brand", "brand_soft", "brand_ink",
|
||||
}
|
||||
pk = settings_table.c["id"]
|
||||
@@ -40,7 +41,7 @@ def test_ui_settings_single_row_nullable_contract() -> None:
|
||||
col = settings_table.c[name]
|
||||
assert col.nullable is True, f"{name} must be NULL (env default)"
|
||||
assert getattr(col.type, "length", None) == 300, f"{name} must be String(300)"
|
||||
for name in ("bg", "surface", "ink", "ink_soft", "line",
|
||||
for name in ("bg", "surface", "ink", "ink_soft", "line", "grid_line",
|
||||
"brand", "brand_soft", "brand_ink"):
|
||||
col = settings_table.c[name]
|
||||
assert col.nullable is True, f"{name} must be NULL (the built-in)"
|
||||
|
||||
@@ -453,7 +453,9 @@ def test_share_button_css_is_the_exact_save_family() -> None:
|
||||
assert "background: var(--brand)" in body, "same solid brand fill as Save"
|
||||
assert "color: var(--bg)" in body, "--bg text on --brand = 5.2:1 (AA)"
|
||||
hover = re.search(r"\.share-chat-btn:hover \{([\s\S]*?)\n\}", css)
|
||||
assert hover and "#f55a72" in hover.group(1), "hover lightens the brand fill"
|
||||
assert hover and "var(--brand-hover)" in hover.group(1), (
|
||||
"hover lightens the brand fill (phase 92: --brand-hover)"
|
||||
)
|
||||
svg = re.search(r"\.share-chat-btn svg \{([\s\S]*?)\n\}", css)
|
||||
assert svg and "display: none" in svg.group(1), "icon hidden on desktop (like Save)"
|
||||
mobile = re.search(r"@media \(max-width: 640px\) \{([\s\S]*?)\n\}", css)
|
||||
@@ -1013,7 +1015,9 @@ def test_stale_banner_css_is_the_kb_banner_family() -> None:
|
||||
):
|
||||
assert prop in body, f".stale-regenerate must keep the Save/Share family ({prop})"
|
||||
hover = re.search(r"\.stale-regenerate:hover \{([\s\S]*?)\n\}", css)
|
||||
assert hover and "#f55a72" in hover.group(1), "hover lightens the brand fill"
|
||||
assert hover and "var(--brand-hover)" in hover.group(1), (
|
||||
"hover lightens the brand fill (phase 92: --brand-hover)"
|
||||
)
|
||||
assert re.search(r"\.stale-regenerate svg \{ width: 16px; height: 16px", css), (
|
||||
"the redo glyph rides the 16px pill size"
|
||||
)
|
||||
|
||||
@@ -600,8 +600,9 @@ def test_sync_result_is_styled() -> None:
|
||||
|
||||
|
||||
def test_sync_modal_css_error_palette_and_stacking() -> None:
|
||||
""".sync-modal-backdrop: fixed, full-viewport, rgba dim, z-index
|
||||
above the sticky header; .sync-modal: the centered ≈28rem panel on
|
||||
""".sync-modal-backdrop: fixed, full-viewport, the --bg-82% dim
|
||||
(phase 92: color-mix of the identity variable), z-index above the
|
||||
sticky header; .sync-modal: the centered ≈28rem panel on
|
||||
the phase-08 error palette (panel on --err-bg, 1px --err-line
|
||||
border, --err-ink error text, --ink title — all computed ≥4.5:1);
|
||||
open/close via .is-open (visibility/opacity)."""
|
||||
@@ -612,7 +613,9 @@ def test_sync_modal_css_error_palette_and_stacking() -> None:
|
||||
assert "position: fixed" in b
|
||||
assert "inset: 0" in b
|
||||
assert "z-index: 1000" in b, "above the sticky header (20) + skip-link (100)"
|
||||
assert "rgba(" in b, "the dim over the page"
|
||||
assert "color-mix(in srgb, var(--bg) 82%, transparent)" in b, (
|
||||
"the dim over the page (phase 92: --bg at 82%)"
|
||||
)
|
||||
open_state = re.search(r"\.sync-modal-backdrop\.is-open\s*\{([^}]*)\}", css)
|
||||
assert open_state, ".is-open must be the open state"
|
||||
assert "visibility: visible" in open_state.group(1)
|
||||
|
||||
+35
-16
@@ -7,12 +7,12 @@ authoring guide before task 03 deleted it) and the DB-over-env /
|
||||
DB-over-built-in resolver shared by ``/api/ui-settings`` and
|
||||
``/api/config``:
|
||||
|
||||
* ``BUILTIN_COLORS`` — the DRIFT GUARD: the 8 built-ins must equal the
|
||||
* ``BUILTIN_COLORS`` — the DRIFT GUARD: the 9 built-ins must equal the
|
||||
values parsed straight out of ``frontend/assets/styles.css``'s
|
||||
``:root`` block, so the Python palette and the stylesheet can never
|
||||
silently diverge;
|
||||
* ``theme_style_tag`` — the byte-identical contract (all built-in →
|
||||
``""``) and the exact tag shape (all 8 variables, ``COLOR_FIELDS``
|
||||
``""``) and the exact tag shape (all 9 variables, ``COLOR_FIELDS``
|
||||
order, lowercased hex);
|
||||
* ``effective_settings`` — missing row → env strings + built-ins; a DB
|
||||
row's set columns win; an empty-string DB string falls back to env
|
||||
@@ -45,6 +45,14 @@ def _delete_row() -> Any:
|
||||
return delete(UiSettings).where(UiSettings.id == 1)
|
||||
|
||||
|
||||
def _start_row_missing(db: Session) -> None:
|
||||
"""The single row is global state: wipe it so every DB test starts
|
||||
from the row-missing state it asserts (a stale row from an earlier
|
||||
interrupted run must not break them)."""
|
||||
db.execute(_delete_row())
|
||||
db.commit()
|
||||
|
||||
|
||||
def _root_declarations() -> dict[str, str]:
|
||||
"""The ``--name: value`` declarations of styles.css's (first)
|
||||
``:root`` block, comments stripped, in file order."""
|
||||
@@ -59,13 +67,13 @@ def _root_declarations() -> dict[str, str]:
|
||||
def test_builtin_colors_match_styles_css_root() -> None:
|
||||
"""The drift guard: every built-in equals the stylesheet's ``:root``
|
||||
value for the same variable (and ``BUILTIN_COLORS`` names exactly
|
||||
the 8 identity variables — no more, no fewer)."""
|
||||
the 9 identity variables — no more, no fewer)."""
|
||||
decls = _root_declarations()
|
||||
builtin_names = set(theming.BUILTIN_COLORS)
|
||||
assert builtin_names == {
|
||||
"bg", "surface", "ink", "ink_soft", "line",
|
||||
"bg", "surface", "ink", "ink_soft", "line", "grid_line",
|
||||
"brand", "brand_soft", "brand_ink",
|
||||
}, f"BUILTIN_COLORS must name exactly the 8 identity variables, got {sorted(builtin_names)}"
|
||||
}, f"BUILTIN_COLORS must name exactly the 9 identity variables, got {sorted(builtin_names)}"
|
||||
for name, value in theming.BUILTIN_COLORS.items():
|
||||
css_name = f"--{name.replace('_', '-')}"
|
||||
assert css_name in decls, f"styles.css :root is missing {css_name}"
|
||||
@@ -75,12 +83,15 @@ def test_builtin_colors_match_styles_css_root() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_color_fields_are_the_eight_keys_in_readme_order() -> None:
|
||||
"""``COLOR_FIELDS`` is the 8 keys in the themes-README order — the
|
||||
order the resolver, the API, and the tag renderer all rely on."""
|
||||
def test_color_fields_are_the_nine_keys_in_readme_order() -> None:
|
||||
"""``COLOR_FIELDS`` is the 9 keys in the themes-README order — the
|
||||
order the resolver, the API, and the tag renderer all rely on.
|
||||
(Phase 92, task 01: ``grid_line`` is the 9th identity variable,
|
||||
slotting in between ``line`` and ``brand`` — structural colors
|
||||
first, brand last.)"""
|
||||
assert theming.COLOR_FIELDS == (
|
||||
"bg", "surface", "ink", "ink_soft",
|
||||
"line", "brand", "brand_soft", "brand_ink",
|
||||
"line", "grid_line", "brand", "brand_soft", "brand_ink",
|
||||
)
|
||||
assert theming.STRING_FIELDS == ("app_name", "input_placeholder", "footer_text")
|
||||
|
||||
@@ -102,7 +113,8 @@ def _env_settings() -> Settings:
|
||||
|
||||
def test_effective_missing_row_is_env_strings_plus_builtins(db: Session) -> None:
|
||||
"""A missing row (GET creates nothing) means "defaults": the env
|
||||
strings + the built-in palette, all 11 keys."""
|
||||
strings + the built-in palette, all 12 keys."""
|
||||
_start_row_missing(db)
|
||||
row = db.execute(select(UiSettings).where(UiSettings.id == 1)).scalars().first()
|
||||
assert row is None, "the test starts from a row-missing state"
|
||||
effective = theming.effective_settings(db, _env_settings())
|
||||
@@ -117,6 +129,7 @@ def test_effective_db_row_wins_column_by_column(db: Session) -> None:
|
||||
"""Set columns win, unset columns fall back — per column, so a
|
||||
partial row (only ``bg`` set) mixes the DB color with the built-ins
|
||||
and the env strings."""
|
||||
_start_row_missing(db)
|
||||
db.add(UiSettings(id=1, bg="#111111", app_name="DB Name"))
|
||||
db.commit()
|
||||
try:
|
||||
@@ -140,6 +153,7 @@ def test_effective_empty_string_db_string_falls_back_to_env(db: Session) -> None
|
||||
Colors: ``None`` → the built-in (an empty color is impossible through
|
||||
the API — the hex validator — the resolver's not-None rule covers
|
||||
the hand-edited edge by returning whatever the row holds)."""
|
||||
_start_row_missing(db)
|
||||
db.add(UiSettings(id=1, app_name=""))
|
||||
db.commit()
|
||||
try:
|
||||
@@ -155,9 +169,10 @@ def test_effective_empty_string_db_string_falls_back_to_env(db: Session) -> None
|
||||
def test_effective_without_explicit_settings_uses_get_settings(db: Session) -> None:
|
||||
"""``settings=None`` (the design's call shape) resolves the env
|
||||
fallback from the cached :func:`app.config.get_settings` — the
|
||||
values it reports must be real ``str``s for all 11 keys."""
|
||||
values it reports must be real ``str``s for all 12 keys."""
|
||||
from app.config import get_settings
|
||||
|
||||
_start_row_missing(db)
|
||||
effective = theming.effective_settings(db)
|
||||
assert set(effective) == set(theming.STRING_FIELDS) | set(theming.COLOR_FIELDS)
|
||||
assert effective["app_name"] == get_settings().app_name
|
||||
@@ -181,16 +196,20 @@ def test_theme_style_tag_all_builtins_is_empty_string() -> None:
|
||||
assert theming.theme_style_tag(colors) != ""
|
||||
|
||||
|
||||
def test_theme_style_tag_one_changed_carries_all_eight_in_order() -> None:
|
||||
"""A single non-built-in color still emits ALL 8 variables, in
|
||||
``COLOR_FIELDS`` order, with the exact tag shape (no whitespace)."""
|
||||
def test_theme_style_tag_one_changed_carries_all_nine_in_order() -> None:
|
||||
"""A single non-built-in color still emits ALL 9 variables, in
|
||||
``COLOR_FIELDS`` order, with the exact tag shape (no whitespace).
|
||||
Phase 92 (task 01): the tag carries ``--grid-line:#4a2626;`` between
|
||||
``--line`` and ``--brand`` (the 9th identity variable — the
|
||||
background grid texture)."""
|
||||
colors = dict(theming.BUILTIN_COLORS)
|
||||
colors["brand"] = "#818cf8"
|
||||
tag = theming.theme_style_tag(colors)
|
||||
assert tag == (
|
||||
'<style id="bor-theme">:root{'
|
||||
"--bg:#0f0a0a;--surface:#1a0f0f;--ink:#f0e6e6;--ink-soft:#b8a8a8;"
|
||||
"--line:#2d1a1a;--brand:#818cf8;--brand-soft:#2d0a0a;--brand-ink:#fca5a5;"
|
||||
"--line:#2d1a1a;--grid-line:#4a2626;--brand:#818cf8;"
|
||||
"--brand-soft:#2d0a0a;--brand-ink:#fca5a5;"
|
||||
"}</style>"
|
||||
)
|
||||
# The changed value lands under the dashed CSS name…
|
||||
@@ -211,7 +230,7 @@ def test_theme_style_tag_multiple_changed() -> None:
|
||||
assert tag.startswith('<style id="bor-theme">:root{--bg:#0a0e1a;')
|
||||
assert "--brand-ink:#c7d2fe;" in tag
|
||||
assert tag.endswith("}</style>")
|
||||
# The order of the 8 dashed names is the COLOR_FIELDS order.
|
||||
# The order of the 9 dashed names is the COLOR_FIELDS order.
|
||||
names = re.findall(r"--([a-z-]+):", tag)
|
||||
assert names == [k.replace("_", "-") for k in theming.COLOR_FIELDS]
|
||||
|
||||
|
||||
@@ -36,7 +36,8 @@ from app.models import UiSettings
|
||||
ALL_NULL_BODY: dict[str, str | None] = {
|
||||
"app_name": None, "input_placeholder": None, "footer_text": None,
|
||||
"bg": None, "surface": None, "ink": None, "ink_soft": None,
|
||||
"line": None, "brand": None, "brand_soft": None, "brand_ink": None,
|
||||
"line": None, "grid_line": None, "brand": None, "brand_soft": None,
|
||||
"brand_ink": None,
|
||||
}
|
||||
|
||||
|
||||
@@ -79,7 +80,7 @@ def test_put_too_long_string_422_names_the_field(
|
||||
|
||||
|
||||
def test_put_bad_hex_422_names_the_field(admin_client: TestClient) -> None:
|
||||
"""Each of the 8 colors: anything not ``^#[0-9a-fA-F]{6}$`` is a 422
|
||||
"""Each of the 9 colors: anything not ``^#[0-9a-fA-F]{6}$`` is a 422
|
||||
naming that field — 3-digit shorthand, 8 hex digits, a bare hex
|
||||
without ``#``, a named color, and the empty string (the color clear
|
||||
operation is ``null``, not ``""``)."""
|
||||
@@ -88,6 +89,12 @@ def test_put_bad_hex_422_names_the_field(admin_client: TestClient) -> None:
|
||||
r = admin_client.put("/api/ui-settings", json={field: bad})
|
||||
assert r.status_code == 422, (field, bad, r.text)
|
||||
assert r.json()["detail"] == f"{field} must be a #rrggbb hex color"
|
||||
# Phase 92 (task 01): the 9th identity color names its 422 the same
|
||||
# fixed way as the other 8 (the loop above already covers it via
|
||||
# COLOR_FIELDS; the explicit case pins the field name in the detail).
|
||||
r = admin_client.put("/api/ui-settings", json={"grid_line": "nope"})
|
||||
assert r.status_code == 422, r.text
|
||||
assert r.json()["detail"] == "grid_line must be a #rrggbb hex color"
|
||||
|
||||
|
||||
def test_put_lowercases_colors_on_store(
|
||||
@@ -127,6 +134,30 @@ def test_put_built_in_color_is_stored_as_null(
|
||||
assert getattr(row, field) is None, f"{field} must be stored as NULL"
|
||||
|
||||
|
||||
def test_put_grid_line_built_in_is_stored_as_null(
|
||||
admin_client: TestClient, db: Session
|
||||
) -> None:
|
||||
"""Phase 92 (task 01): the 9th identity color gets the same
|
||||
owner-locked normalization as the other 8 — PUTting the built-in
|
||||
grid hex stores NULL (the response still reports the built-in, and
|
||||
the row's grid column stays empty); a NON-built-in value is stored
|
||||
as-is (lowercased)."""
|
||||
r = admin_client.put("/api/ui-settings", json={"grid_line": "#4a2626"})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["grid_line"] == theming.BUILTIN_COLORS["grid_line"]
|
||||
row = _row(db)
|
||||
assert row is not None
|
||||
assert row.grid_line is None # built-in → NULL
|
||||
|
||||
r = admin_client.put("/api/ui-settings", json={"grid_line": "#123123"})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["grid_line"] == "#123123"
|
||||
db.expire_all() # drop the test session's pre-second-PUT view (house pattern)
|
||||
row = _row(db)
|
||||
assert row is not None
|
||||
assert row.grid_line == "#123123" # non-built-in is stored as-is
|
||||
|
||||
|
||||
def test_put_empty_string_is_the_clear_operation(
|
||||
admin_client: TestClient, db: Session
|
||||
) -> None:
|
||||
@@ -147,7 +178,7 @@ def test_put_empty_string_is_the_clear_operation(
|
||||
def test_get_effective_merge_partial_row(admin_client: TestClient, db: Session) -> None:
|
||||
"""GET reports the DB values over the defaults, column by column: a
|
||||
row with ONLY ``bg`` set (hand-inserted) reports that color plus the
|
||||
built-ins and the env strings — all 11 keys, no nulls."""
|
||||
built-ins and the env strings — all 12 keys, no nulls."""
|
||||
db.add(UiSettings(id=1, bg="#123456"))
|
||||
db.commit()
|
||||
r = admin_client.get("/api/ui-settings")
|
||||
|
||||
Reference in New Issue
Block a user