"""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 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 ", "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 ", 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"]*\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"]*\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