tests/test_theming.py
Rama main · réplica de lectura del repositorio original.
"""Per-account colour overrides.
Three things here are guarantees rather than conveniences:
* **A blank field means the default, not an empty colour.** Stored as an
absent key, so the stylesheet shows through and a later change to the
built-in palette still reaches that person.
* **Only the six documented tokens are storable.** A row naming something
else is dead weight a future version might misread.
* **The defaults listed here are the ones in style.css.** Two lists of
colours in two files is the arrangement that drifts, so a test compares
them rather than trusting a comment.
"""
from __future__ import annotations
import re
from pathlib import Path
import pytest
from app import theming
from test_routes import make_login, session_csrf
HTML = {"accept": "text/html"}
CSS = Path(__file__).resolve().parents[1] / "app" / "static" / "style.css"
# --------------------------------------------------------------------------- #
# The seam
# --------------------------------------------------------------------------- #
@pytest.mark.parametrize("raw,expected", [
("#abc", "#aabbcc"),
("#AABBCC", "#aabbcc"),
(" #a1b2c3 ", "#a1b2c3"),
])
def test_a_colour_is_normalised_to_six_lowercase_digits(raw, expected):
"""What is stored must round-trip through the <input type="color"> beside
it, which only speaks #rrggbb."""
assert theming.normalise(raw) == expected
@pytest.mark.parametrize("raw", ["red", "rgb(1,2,3)", "#12345", "", "#ggg", None])
def test_anything_that_is_not_a_hex_colour_is_refused(raw):
assert theming.normalise(raw) is None
def test_a_blank_field_stores_nothing_rather_than_an_empty_colour():
"""The difference between "use the default" and "use no colour"."""
assert theming.clean({"light-accent": "", "light-up": "#123456"}) == {
"light": {"up": "#123456"}}
def test_an_unknown_token_or_mode_is_dropped():
assert theming.clean({"light-accent": "#123456", "light-bg": "#000000",
"sepia-up": "#111111", "accent": "#222222"}) == {
"light": {"accent": "#123456"}}
def test_each_mode_keeps_its_own_colours():
"""One colour per mode: a colour that reads on the light card rarely reads
on the dark one (decisions.md #89)."""
assert theming.clean({"light-up": "#111111", "dark-up": "#eeeeee"}) == {
"light": {"up": "#111111"}, "dark": {"up": "#eeeeee"}}
def _rules(css: str) -> dict[str, str]:
"""{selector, with any @media wrapped round it: declarations}."""
found = {}
for media, selector, body in re.findall(
r"(?:(@media [^{]+)\{)?([^{}@]+)\{([^}]*)\}\}?", css):
found[(media.strip() + " " if media else "") + selector.strip()] = body
return found
def test_a_light_colour_applies_only_where_the_page_is_light():
rules = _rules(theming.stylesheet({"light": {"up": "#112233"}}))
assert rules == {
"body[data-colors][data-theme=light]": "--up:#112233",
"@media (prefers-color-scheme: light) body[data-colors][data-theme=auto]": "--up:#112233",
}
def test_a_dark_colour_applies_only_where_the_page_is_dark():
rules = _rules(theming.stylesheet({"dark": {"down": "#ff6666"}}))
assert rules == {
"body[data-colors][data-theme=dark]": "--down:#ff6666",
"@media (prefers-color-scheme: dark) body[data-colors][data-theme=auto]": "--down:#ff6666",
}
def test_nothing_but_a_checked_colour_reaches_the_stylesheet():
"""The rules are written into a <style> element, so a stored value is
checked again on the way out, as a colour or nothing."""
css = theming.stylesheet({"light": {"up": "#fff}</style><script>x()</script>",
"bg": "#000000", "down": "#abc"}})
assert css == ("body[data-colors][data-theme=light]{--down:#aabbcc}"
"@media (prefers-color-scheme: light)"
"{body[data-colors][data-theme=auto]{--down:#aabbcc}}")
@pytest.mark.parametrize("accent", ["#0f766e", "#fbbf24", "#7c3aed", "#888888", "#ff8800"])
def test_a_chosen_accent_paints_the_buttons_in_words_that_read_on_it(accent):
"""Only `--accent` was set, but buttons paint `--accent-grad`, which kept
the theme's own colours: the swatch promised "Buttons, links…" and moved
no button. The words on them follow the colour too."""
[body] = set(_rules(theming.stylesheet({"dark": {"accent": accent}})).values())
style = dict(part.split(":", 1) for part in body.split(";"))
stops = re.findall(r"#[0-9a-f]{6}", style["--accent-grad"])
words = style["--on-accent"]
assert style["--accent"] == accent and stops[0] == accent and len(stops) == 2
assert words in ("#ffffff", "#0d1018")
# The better of the two, and the far end no harder to read than the near.
other = "#0d1018" if words == "#ffffff" else "#ffffff"
assert theming.contrast(words, accent) >= theming.contrast(other, accent)
assert theming.contrast(words, stops[1]) >= theming.contrast(words, stops[0]) >= 4.5
def test_changing_the_first_series_moves_the_positive_pole_with_it():
"""`--viz-pos` IS series 1 in the built-in palette. If it did not follow,
a chart would keep drawing gains in the colour that was replaced."""
[body] = set(_rules(theming.stylesheet({"light": {"viz-1": "#445566"}})).values())
assert body == "--viz-1:#445566;--viz-pos:#445566"
def test_nothing_chosen_renders_no_style_at_all():
"""An empty <style> is noise in every page's source."""
assert theming.stylesheet(None) == ""
assert theming.stylesheet({}) == ""
assert theming.stylesheet({"light": {}, "dark": {}}) == ""
def test_each_built_in_colour_meets_its_floor_on_its_own_card():
"""One value had to serve both modes, and no colour is 4.5:1 on both
cards, so the floor was 3:1 for everything. Per mode, text is held to 4.5:1
and a chart's lines to 3:1 — the built-in colours included, or a person
choosing one back would be warned about the app's own."""
weak = {(mode, s.token): theming.contrast(theming.DEFAULTS[mode][s.token],
theming.SURFACES[mode])
for mode in theming.MODES for s in theming.SWATCHES
if theming.contrast(theming.DEFAULTS[mode][s.token],
theming.SURFACES[mode]) < s.floor}
assert weak == {}
assert {s.token: s.floor for s in theming.SWATCHES} == {
"accent": 4.5, "up": 4.5, "down": 4.5, "viz-1": 3.0, "viz-2": 3.0, "viz-3": 3.0}
def test_a_colour_too_close_to_the_card_behind_it_is_flagged():
"""Advice, not a veto — but silence would be worse than either."""
warnings = theming.warnings({"dark": {"accent": "#1b2030"}}) # the dark panel itself
assert list(warnings) == ["dark"] and list(warnings["dark"]) == ["accent"]
def test_text_is_held_to_the_text_floor_and_a_chart_to_the_graphic_one():
"""#8a7f3f measures 3.81:1 on the light card: enough for a chart's line,
not for the words in a gain cell."""
assert theming.contrast("#8a7f3f", theming.SURFACES["light"]) == 3.81
warnings = theming.warnings({"light": {"up": "#8a7f3f", "viz-1": "#8a7f3f"}})
assert list(warnings["light"]) == ["up"]
assert "4.5:1" in warnings["light"]["up"]
def test_each_mode_is_measured_on_its_own_card():
"""Near-white is fine on the dark card and invisible on the light one."""
assert theming.warnings({"dark": {"up": "#fdfdfd"}}) == {}
assert list(theming.warnings({"light": {"up": "#fdfdfd"}})["light"]) == ["up"]
def test_the_documented_defaults_are_the_ones_in_the_stylesheet():
"""The placeholder in each box claims to be the built-in colour. If these
two lists drift, it claims something false."""
css = CSS.read_text(encoding="utf-8")
blocks = {"light": css[css.index(":root {"):css.index("/* ── dark surfaces")],
"dark": re.search(r'\nbody\[data-theme="dark"\] \{([^}]*)\}', css).group(1)}
for mode, block in blocks.items():
for token, value in theming.DEFAULTS[mode].items():
found = re.search(rf"--{re.escape(token)}:\s*(#[0-9a-fA-F]{{6}})", block)
assert found, f"--{token} is not declared for {mode}"
assert found.group(1).lower() == value, (
f"{mode} --{token} is {found.group(1)} in style.css but {value} here")
# --------------------------------------------------------------------------- #
# Through the route
# --------------------------------------------------------------------------- #
def _save(client, session_factory, **colours):
return client.post("/profile/colors",
data={**colours, "_csrf": session_csrf(session_factory)},
headers=HTML, follow_redirects=True)
def test_a_saved_colour_reaches_the_page(client, session_factory):
"""The end of the chain: form -> row -> the page's <style>."""
make_login(client, session_factory)
_save(client, session_factory, **{"dark-accent": "#ff8800"})
page = client.get("/", headers=HTML).text
style = re.search(r"<style>(.*?)</style>", page, re.S).group(1)
assert "body[data-colors][data-theme=dark]{--accent:#ff8800;" in style
assert "--accent-grad:linear-gradient(135deg, #ff8800, " in style
assert re.search(r"<body [^>]*\bdata-colors\b", page)
def test_saving_junk_leaves_the_palette_alone(client, session_factory):
make_login(client, session_factory)
_save(client, session_factory, **{"light-accent": "octarine"})
page = client.get("/", headers=HTML).text
assert "octarine" not in page
assert "--accent:" not in page
assert not re.search(r"<body [^>]*\bdata-colors\b", page)
def test_reset_stores_null_rather_than_a_copy_of_the_defaults(client, session_factory):
"""The same distinction the columns reset makes: a stored copy would freeze
today's colours into the account and opt it out of every future change."""
from sqlalchemy import select
from app.models import User
email = make_login(client, session_factory)
_save(client, session_factory, **{"light-accent": "#ff8800"})
client.post("/profile/colors/reset",
data={"_csrf": session_csrf(session_factory)},
headers=HTML, follow_redirects=True)
with session_factory() as s:
stored = s.scalar(select(User).where(User.email == email)).theme_colors
assert stored is None
def test_the_account_page_offers_every_colour_in_each_mode(client, session_factory):
make_login(client, session_factory)
_save(client, session_factory, **{"dark-up": "#ffffff"})
page = client.get("/profile", headers=HTML).text
for mode in theming.MODES:
for swatch in theming.SWATCHES:
assert f'name="{mode}-{swatch.token}"' in page, f"{mode} {swatch.token} has no field"
assert f'placeholder="{theming.DEFAULTS[mode][swatch.token]}"' in page
# Heard without the tab, so each names its mode: "a dark colour"
# reads as a shade.
assert f'aria-label="Pick the {swatch.label} colour for {mode} mode"' in page
assert f'aria-label="{swatch.label} colour code for {mode} mode"' in page
assert 'value="#ffffff"' in page
assert 'role="tab"' in page and "/static/colourtabs.js" in page
assert "/static/colourpicker.js" in page
# --------------------------------------------------------------------------- #
# Portfolio settings
# --------------------------------------------------------------------------- #
def test_a_portfolio_can_be_renamed(client, session_factory):
"""It could be named once, at creation, and never again."""
make_login(client, session_factory)
client.post("/portfolio/settings",
data={"name": "Family trust", "reporting_currency": "AUD",
"jurisdiction": "AU", "_csrf": session_csrf(session_factory)},
headers=HTML, follow_redirects=True)
page = client.get("/", headers=HTML).text
assert "Family trust" in page
def test_the_currency_and_country_are_stored(client, session_factory):
"""Stored, and — for now — only stored. The page says so; this pins that
the value survives the round trip so T28b has something to read."""
from sqlalchemy import select
from app.models import Portfolio
make_login(client, session_factory)
client.post("/portfolio/settings",
data={"name": "Mine", "reporting_currency": "NZD",
"jurisdiction": "NZ", "_csrf": session_csrf(session_factory)},
headers=HTML, follow_redirects=True)
with session_factory() as s:
pf = s.scalars(select(Portfolio)).first()
assert pf.reporting_currency == "NZD"
assert pf.jurisdiction == "NZ"
def test_an_empty_name_is_refused(client, session_factory):
make_login(client, session_factory)
resp = client.post("/portfolio/settings",
data={"name": " ", "reporting_currency": "AUD",
"jurisdiction": "AU", "_csrf": session_csrf(session_factory)},
headers=HTML)
assert resp.status_code == 400
def test_a_currency_the_app_cannot_render_is_refused(client, session_factory):
"""`money.SYMBOLS` decides what can be displayed. Storing something outside
it would render as the raw code in every total."""
make_login(client, session_factory)
resp = client.post("/portfolio/settings",
data={"name": "Mine", "reporting_currency": "ZZZ",
"jurisdiction": "AU", "_csrf": session_csrf(session_factory)},
headers=HTML)
assert resp.status_code == 400
def test_the_admin_page_says_the_currency_is_not_applied_yet(client, session_factory):
"""A setting that silently does nothing is worse than no setting."""
make_login(client, session_factory)
page = client.get("/members", headers=HTML).text
assert "not applied yet" in page
# --------------------------------------------------------------------------- #
# The Settings page console
# --------------------------------------------------------------------------- #
def test_the_console_keeps_warnings_and_above_but_not_info():
"""The level IS the design. Error-only says something broke and nothing
about what led there; INFO is one line per web request and buries it."""
import logging
from app import logbuffer
logbuffer.clear()
logbuffer.install()
log = logging.getLogger("test.console")
log.info("a routine request")
log.warning("the feed fell back to its secondary source")
log.error("the import failed")
kept = [line.message for line in logbuffer.recent()]
assert "a routine request" not in kept
assert "the feed fell back to its secondary source" in kept
assert "the import failed" in kept
def test_tailing_asks_for_what_it_has_not_seen():
"""The page keeps the lines it was first shown and appends. Without a
sequence it would have to re-fetch the window and work out the difference,
which is how a console starts duplicating lines."""
import logging
from app import logbuffer
logbuffer.clear()
logbuffer.install()
log = logging.getLogger("test.console")
log.warning("first")
log.warning("second")
seen = logbuffer.recent()[-1].seq
log.warning("third")
fresh = logbuffer.since(seen)
assert [line.message for line in fresh] == ["third"]
def test_a_burst_longer_than_the_buffer_returns_what_is_left():
"""The reader has missed lines either way; showing the recent ones beats
showing none."""
import logging
from app import logbuffer
logbuffer.clear()
logbuffer.install()
log = logging.getLogger("test.console")
for i in range(logbuffer.CAPACITY + 50):
log.warning("line %d", i)
fresh = logbuffer.since(1)
assert len(fresh) == logbuffer.CAPACITY
def test_the_console_endpoint_is_admin_only(client, session_factory):
"""It reports on the whole installation, not one portfolio."""
make_login(client, session_factory)
resp = client.get("/admin/logs.json")
assert resp.status_code == 200 # the test login is an admin
assert "lines" in resp.json()
def test_the_settings_page_renders_the_opening_window(client, session_factory):
"""Server-rendered, so the console is useful with scripting off."""
import logging
from app import logbuffer
make_login(client, session_factory)
logbuffer.clear()
logbuffer.install()
logging.getLogger("test.console").warning("something worth reading")
page = client.get("/admin/settings", headers=HTML).text
assert "something worth reading" in page
assert "/static/console.js" in page