mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
* fix(element-style): clamp out-of-range colour components instead of rejecting
A regression this framework shipped. The eight scoreboards used to read their
colours through sports_card.coerce_rgb, which clamps; routing them through the
shared element_color sent them through _normalize_color, which rejected any
component outside 0..255 and fell back to the default. So a configured
[999, -5, 20] -- a typo'd bright red -- rendered white instead of (255, 0, 20).
Their own test_element_text_colors.py caught it: one case of nineteen, in all
eight plugins, failing only once the core change reached main.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(web): the style editor takes over its own blocks -- and gets to at all
Two defects, both found by rendering the real partial in a browser rather than
by reading the code.
It was losing a race to its own fields. The hand-off guard asked "do any
fallback controls differ from their server-rendered defaults?" as a proxy for
"is someone editing this?". But the fallback holds this block's own font
fields, and the font-selector widget populates them on the same 50ms timer --
so a plain page load, with nobody touching anything, raced into "dirty" and the
editor removed itself, leaving the 701-line accordion form it exists to
replace. Measured: seven customization.*.font selects dirty ~60ms after
injection, clean again by 400ms. The question is whether a *person* typed, and
event.isTrusted answers exactly that; the listeners now go on synchronously,
because the edit worth protecting can happen before initWidget runs.
It took over too much. Taking over removed the whole fallback section, but a
customization block can hold more than styling -- football keeps
favorite_result_colors there -- so that removed the only UI those fields had,
and the editor also rendered them as an element, giving every row an "enabled"
and three colour columns. Core now marks the blocks it recognises as styling
(the compact declaration already did; hand-written adoption did not), the
widget renders only those, and the template drops only the children the widget
reports owning.
Verified on football's real schema: 28 rows across four mode tabs, columns
Element/Font/Size/Colour/X/Y, favorite_result_colors still editable with its
ten inputs, no duplicated field names, no console errors.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(web): style editor no longer drops layout-only fields it never rendered
CodeRabbit flagged elementKeys() in style-editor.js: render() claims the
whole customization.layout child as the widget's own (removing it from the
generic fallback renderer, since posting the same offset twice is worse),
but elementKeys() only listed keys that also have their own top-level style
block. A hand-written schema can put a key under layout that never got one
-- a logo, a timeout indicator, a possession arrow with a position but no
font or colour -- and that key's only control silently disappeared: no row
in the style editor's table (elementKeys never listed it) and no fallback
section either (layout was removed wholesale).
elementKeys() now appends any layout-declared key not already covered by a
style element, so table() renders a row for it (layout columns only, no
style columns) and the wholesale layout ownership claim stays truthful.
Verified against current code before fixing. New regression test
(test/js/unit/test_style_editor_element_keys.js, following this repo's
existing eval-extraction pattern for testing widget JS without a browser)
fails against the reverted function and passes with the fix; added to
run_all.js and the suite table in test/js/README.md.
Full pytest suite: 4887 passed, 62 skipped, 2 failed -- both the
pre-existing Europe/Kiev/Asia/Calcutta tzdata-alias gap on this sandbox,
identical on origin/main, unrelated to this change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dpg3HLWohdCUdzz2QNHanm
* fix(web): style editor no longer strands leaf-valued layout fields
A prior fix on this PR made elementKeys() append any layout-only key with
no style block of its own (a logo, a timeout indicator, a possession
arrow), so table() draws a row for it instead of losing it when the
wholesale `layout` claim removes the generic fallback. That covers a
layout-only key shaped like an object (x_offset/y_offset, ...), because
columnsFor() only ever produced columns from a key's *sub-fields*.
It missed the case where the layout-only key's own value is itself a
leaf -- a plain "show_logo" boolean directly under layout, no x/y object
underneath. elementKeys() still lists it (any row: no matching column),
so it renders as an uneditable blank row and its only control -- the
generic fallback checkbox -- is still gone. Confirmed by executing the
real widget's render() against a synthetic schema in Node (a DOM-stub
harness, not committed): the field's name never appeared as an <input>.
columnsFor() now gives such a leaf key a column keyed to itself
('layout-leaf'), and elementRow() binds it to the leaf's own path
(customization.layout.<key>, matching the name the fallback would have
used) instead of leaving every cell blank.
New regression test (test/js/unit/test_style_editor_layout_leaf_columns.js,
following this PR's existing eval-extraction pattern) checks the leaf
column is produced, is self-keyed, doesn't duplicate, and that a schema
with no leaf-valued layout key is unaffected; wired into run_all.js and
the suite table in test/js/README.md.
test/js/run_all.js: 84 + 6 + 6 = all suites passed (jsdom unavailable
here, DOM suites skip as before). Python suite untouched by this change;
test_style_editor_extra_fields.py, test_style_editor_save_roundtrip.py
and the one PIL-dependent style_editor_takeover.py case fail identically
before this commit -- missing flask/PIL in this sandbox, not this PR.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(web): keep layout-leaf style-editor columns distinct from name collisions
columnsFor() keyed a layout-only leaf field's column by its bare field
name. If an unrelated element's style block or another element's layout
axis block happened to declare a sub-field with that same name, the
`!seen.has(key)` guard skipped creating the leaf's column, silently
dropping its only control again -- the same failure the leaf-column fix
was meant to close, just reached through a name collision (CodeRabbit
review on 324a7ea).
Key layout-leaf columns under a namespaced id so they can never be
shadowed by an unrelated column sharing their name.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(web): CSS.escape() the owned key before it becomes a selector
container.dataset.ownedKeys round-trips schema property keys through a
DOM dataset attribute, and the takeover handoff spliced each one
straight into '[data-child-key="' + k + '"]' with no escaping --
inconsistent with this codebase's own convention elsewhere
(plugin-file-manager.js, app-shell.js's escapeCssSelector) for building
a selector from a dynamic value. A key containing a quote or backslash
would break the selector or be steerable; Codacy's static analysis
flagged this pattern (1 high ErrorProne finding on PR #569, current
head at the time) as a new issue, though its dashboard is unreachable
from this sandbox (egress to app.codacy.com is blocked) and the
check-run API returned no detail text -- verified and fixed by reading
the diff directly rather than the tool's own description.
Added a source-assertion regression test alongside this file's
existing ones (this behavior lives in an inline script no Python test
executes).
Full suite: 4888 passed, 62 skipped, 2 failed -- both the pre-existing
Europe/Kiev/Asia/Calcutta tzdata-alias gap in this sandbox, identical
on origin/main, unrelated to this change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(web): every advertised layout offset gets a control in the style editor
The editor took the whole layout section over but matched offsets to style
rows by exact key. A hand-written schema's two blocks were never named alike --
football styles score_text but positions score -- so of football's eleven
positionable things only status_text had a control. Score, odds, both logos,
timeouts, possession, down-and-distance, date, time and records were options
the schema advertised and the renderer reads, reachable nowhere in the UI.
Core now resolves each style element's layout key through alias_keys, the map
the resolver already reads offsets with, and records it as x-layout-key. The
widget reads that rather than carrying a second copy of the rules, and posts
under the key the schema declares: football's own offset reader looks up
layout.score, so a value saved as layout.score_text would be kept and never
drawn. Layout entries no style element claims get an "Other positions" table
with its own columns, in every mode panel as well as the base one, in the order
the plugin declared them.
Verified in a browser against football's real schema: 92 of 92 layout fields
(23 base, 23 per mode) rendered exactly once under their declared names, none
posted under a style key, no duplicated field names, favorite_result_colors
still editable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
349 lines
16 KiB
Python
349 lines
16 KiB
Python
"""The card helpers the eight scoreboards now share.
|
|
|
|
These bodies lived in eight byte-identical copies. Moving them here means one
|
|
fix reaches every scoreboard — and that a mistake does too, which is what this
|
|
file guards. Each case below is one the plugins' own code already handled; the
|
|
point is that it keeps handling it.
|
|
|
|
The functions take ``config``/``logger``/``fonts`` as arguments rather than
|
|
reading them off an instance, so a plugin keeps its method and delegates the
|
|
body. That is what let all eight adopt this with byte-identical renders.
|
|
"""
|
|
|
|
import logging
|
|
import json
|
|
import os
|
|
|
|
import pytest
|
|
|
|
from src.common import sports_card as C
|
|
|
|
|
|
@pytest.fixture
|
|
def log():
|
|
return logging.getLogger("test_sports_card")
|
|
|
|
|
|
class TestSettingsLookup:
|
|
def test_reads_the_scroll_card_block(self):
|
|
cfg = {"scroll_card": {"vs_text": "@"}}
|
|
assert C.scroll_card_option(cfg, "vs_text", "VS") == "@"
|
|
|
|
@pytest.mark.parametrize("cfg", [None, {}, {"scroll_card": None},
|
|
{"scroll_card": {"vs_text": None}}])
|
|
def test_missing_or_null_falls_back(self, cfg):
|
|
"""A null in config means "unset", not "empty string"."""
|
|
assert C.scroll_card_option(cfg, "vs_text", "VS") == "VS"
|
|
|
|
def test_upcoming_center_rejects_unknown_modes(self):
|
|
for bad in ("sideways", "", None, 7):
|
|
assert C.upcoming_center_mode({"scroll_card": {"upcoming_center": bad}}) == "vs"
|
|
assert C.upcoming_center_mode({"scroll_card": {"upcoming_center": "DATE_TIME"}}) == "date_time"
|
|
|
|
|
|
class TestColour:
|
|
def test_rgb_list_and_hex_both_work(self):
|
|
assert C.element_color({"customization": {"score_text": {"text_color": [1, 2, 3]}}},
|
|
"score_text") == (1, 2, 3)
|
|
assert C.element_color({"customization": {"score_text": {"text_color": "#ff8000"}}},
|
|
"score_text") == (255, 128, 0)
|
|
|
|
@pytest.mark.parametrize("value", ["nope", "#fff", [1, 2], None, ["a", "b", "c"]])
|
|
def test_unusable_colour_falls_back(self, value):
|
|
cfg = {"customization": {"score_text": {"text_color": value}}}
|
|
assert C.element_color(cfg, "score_text", (9, 9, 9)) == (9, 9, 9)
|
|
|
|
def test_out_of_range_components_are_clamped_not_rejected(self):
|
|
"""The readers this replaced clamped, and the eight scoreboards' own
|
|
tests pin it. Rejecting instead turned a typo'd [999, -5, 20] -- a
|
|
bright red -- into the default white, which is a colour the user never
|
|
asked for rather than the one they nearly asked for."""
|
|
cfg = {"customization": {"score_text": {"text_color": [999, -5, 20]}}}
|
|
assert C.element_color(cfg, "score_text", (9, 9, 9)) == (255, 0, 20)
|
|
|
|
def test_coerce_rgb_clamps_rather_than_rejecting(self):
|
|
assert C.coerce_rgb([300, -5, 20], (0, 0, 0)) == (255, 0, 20)
|
|
|
|
def test_coerce_rgb_refuses_a_three_character_string(self):
|
|
""""123" would otherwise iterate into three digits and yield a colour."""
|
|
assert C.coerce_rgb("123", (7, 7, 7)) == (7, 7, 7)
|
|
|
|
def test_font_colour_is_resolved_by_identity(self):
|
|
a, b = object(), object()
|
|
cfg = {"customization": {"score_text": {"text_color": [4, 5, 6]}}}
|
|
assert C.font_color(cfg, {"score": a, "team": b}, a) == (4, 5, 6)
|
|
|
|
def test_a_shared_face_takes_the_one_colour_that_was_configured(self):
|
|
"""A face shared by two elements used to go out white even when only
|
|
one of them had a colour set -- and a bitmap face is *always* shared,
|
|
because a freetype.Face cannot be re-instantiated to un-share it. That
|
|
is how an element in any of the 32 shipped BDF fonts silently lost the
|
|
colour its picker had offered all along. One configured colour among
|
|
the sharers is the only thing the user can have meant."""
|
|
shared = object()
|
|
cfg = {"customization": {"score_text": {"text_color": [4, 5, 6]}}}
|
|
assert C.font_color(cfg, {"score": shared, "team": shared},
|
|
shared) == (4, 5, 6)
|
|
|
|
def test_a_shared_face_still_gives_up_when_the_colours_disagree(self):
|
|
"""Two different answers is the case with no right answer."""
|
|
shared = object()
|
|
cfg = {"customization": {"score_text": {"text_color": [4, 5, 6]},
|
|
"team_name": {"text_color": [7, 8, 9]}}}
|
|
assert C.font_color(cfg, {"score": shared, "team": shared},
|
|
shared) == (255, 255, 255)
|
|
|
|
def test_sharers_that_agree_resolve_to_that_colour(self):
|
|
shared = object()
|
|
cfg = {"customization": {"score_text": {"text_color": [4, 5, 6]},
|
|
"team_name": {"text_color": [4, 5, 6]}}}
|
|
assert C.font_color(cfg, {"score": shared, "team": shared},
|
|
shared) == (4, 5, 6)
|
|
|
|
def test_an_unconfigured_shared_face_keeps_the_default(self):
|
|
shared = object()
|
|
assert C.font_color({}, {"score": shared, "team": shared}, shared,
|
|
(9, 9, 9)) == (9, 9, 9)
|
|
|
|
|
|
class TestFavourites:
|
|
GAME = {"home_abbr": "TB", "away_abbr": "NO", "home_score": "21", "away_score": "17"}
|
|
|
|
def test_win_loss_and_tie(self):
|
|
cfg = {"favorite_teams": ["TB"]}
|
|
assert C.favorite_result(cfg, self.GAME) == "win"
|
|
assert C.favorite_result({"favorite_teams": ["NO"]}, self.GAME) == "loss"
|
|
tied = dict(self.GAME, home_score="3", away_score="3")
|
|
assert C.favorite_result(cfg, tied) == "tie"
|
|
|
|
def test_no_verdict_without_exactly_one_favourite_side(self):
|
|
assert C.favorite_result({}, self.GAME) is None
|
|
assert C.favorite_result({"favorite_teams": ["TB", "NO"]}, self.GAME) is None
|
|
assert C.favorite_result({"favorite_teams": ["SEA"]}, self.GAME) is None
|
|
|
|
def test_unusable_scores_give_no_verdict(self):
|
|
bad = dict(self.GAME, home_score="x")
|
|
assert C.favorite_result({"favorite_teams": ["TB"]}, bad) is None
|
|
|
|
def test_nested_payload_shape_is_read_too(self):
|
|
game = {"home_team": {"abbrev": "TB", "score": 9},
|
|
"away_team": {"abbrev": "NO", "score": 2}}
|
|
assert C.side_score(game, "home") == 9
|
|
assert C.side_is_favorite(game, "home", {"TB"}) is True
|
|
|
|
def test_matches_on_id_where_abbreviations_collide(self):
|
|
"""NRL keys favourites by ESPN id; abbreviations are not unique there."""
|
|
game = {"home_abbr": "SYD", "home_id": "4321"}
|
|
assert C.side_is_favorite(game, "home", {"4321"}) is True
|
|
|
|
def test_game_and_config_favourites_are_both_used(self):
|
|
"""Games carry resolved dynamic groups; config catches later edits."""
|
|
game = dict(self.GAME, favorite_teams=["NO"], league="nfl")
|
|
assert set(C.favorite_teams_for({"nfl": {"favorite_teams": ["TB"]}}, game)) == {"NO", "TB"}
|
|
|
|
|
|
class TestDateAndTime:
|
|
def test_date_formats(self, log):
|
|
for fmt, want in [("abbrev", "Sep 5"), ("numeric", "9/5"),
|
|
("day_first", "5 Sep"), ("numeric_day_first", "5/9")]:
|
|
cfg = {"scroll_card": {"date_format": fmt}}
|
|
assert C.format_game_date(cfg, log, "9/5") == want
|
|
|
|
@pytest.mark.parametrize("raw", ["", "garbage", "13/40", "no/slash/here"])
|
|
def test_unparseable_dates_pass_through(self, log, raw):
|
|
assert C.format_game_date({}, log, raw) == raw.strip()
|
|
|
|
def test_24h_conversion(self):
|
|
cfg = {"scroll_card": {"time_format": "24h"}}
|
|
assert C.format_game_time(cfg, "7:30 PM") == "19:30"
|
|
assert C.format_game_time(cfg, "12:00 AM") == "00:00"
|
|
assert C.format_game_time(cfg, "12:15 PM") == "12:15"
|
|
|
|
def test_12h_is_left_alone_and_junk_survives(self):
|
|
assert C.format_game_time({}, "7:30 PM") == "7:30 PM"
|
|
assert C.format_game_time({"scroll_card": {"time_format": "24h"}}, "soon") == "soon"
|
|
|
|
def test_a_bad_timezone_falls_back_to_utc(self, log):
|
|
"""A typo in config should not blank the card."""
|
|
from datetime import timezone
|
|
assert C.card_tzinfo({"timezone": "Not/AZone"}, log) is timezone.utc
|
|
|
|
|
|
class TestFontSizing:
|
|
def test_snaps_to_the_faces_pixel_grid(self):
|
|
assert C.crisp_size("4x6-font.ttf", 6) == 7 # 7px grid
|
|
assert C.crisp_size("PressStart2P-Regular.ttf", 10) == 8
|
|
assert C.crisp_size("PressStart2P-Regular.ttf", 13) == 16
|
|
|
|
def test_an_unknown_face_is_never_second_guessed(self):
|
|
assert C.crisp_size("SomeUserFont.ttf", 11) == 11
|
|
|
|
def test_aliases_resolve_before_the_grid_lookup(self):
|
|
assert C.crisp_size("four_by_six", 6) == C.crisp_size("4x6-font.ttf", 6)
|
|
|
|
@pytest.mark.parametrize("desired", [0, -3, None])
|
|
def test_unusable_sizes_pass_through_without_raising(self, desired):
|
|
"""None reached this in the field; football's variant raised TypeError."""
|
|
assert C.crisp_size("4x6-font.ttf", desired) == desired
|
|
|
|
def test_schema_cache_is_keyed_per_schema_not_globally(self, tmp_path):
|
|
"""Two plugins declaring different defaults must not share an answer."""
|
|
a, b = tmp_path / "a.json", tmp_path / "b.json"
|
|
for path, size in ((a, 11), (b, 22)):
|
|
path.write_text(json.dumps({"properties": {"customization": {"properties": {
|
|
"score_text": {"properties": {"font_size": {"default": size}}}}}}}))
|
|
assert C.schema_font_size(str(a), "score_text") == 11
|
|
assert C.schema_font_size(str(b), "score_text") == 22
|
|
|
|
def test_a_missing_schema_is_not_an_error(self, tmp_path):
|
|
assert C.schema_font_size(str(tmp_path / "nope.json"), "score_text") is None
|
|
|
|
def test_a_configured_size_matching_the_schema_default_is_not_a_choice(self, tmp_path):
|
|
"""The web UI writes the whole default block on every save, so
|
|
font_size == schema default carries no intent and must not pin the
|
|
install to an off-grid size forever."""
|
|
schema = tmp_path / "s.json"
|
|
schema.write_text(json.dumps({"properties": {"customization": {"properties": {
|
|
"score_text": {"properties": {"font_size": {"default": 10}}}}}}}))
|
|
got = C.resolve_font_size(str(schema), {"font_size": 10}, "score_text", 10,
|
|
"PressStart2P-Regular.ttf")
|
|
assert got == 8, "a default-valued size should snap to the grid"
|
|
|
|
def test_a_real_choice_wins(self, tmp_path):
|
|
schema = tmp_path / "s.json"
|
|
schema.write_text(json.dumps({"properties": {"customization": {"properties": {
|
|
"score_text": {"properties": {"font_size": {"default": 10}}}}}}}))
|
|
got = C.resolve_font_size(str(schema), {"font_size": 13}, "score_text", 10,
|
|
"PressStart2P-Regular.ttf")
|
|
assert got == 13, "an explicit size the user chose is not second-guessed"
|
|
|
|
|
|
class TestTables:
|
|
def test_every_font_key_maps_to_an_element(self):
|
|
assert set(C.ELEMENT_FOR_FONT) == {"score", "time", "team", "status", "detail", "rank"}
|
|
|
|
def test_result_colours_cover_every_verdict(self):
|
|
assert set(C.FAVORITE_RESULT_COLOR_DEFAULTS) == {"win", "loss", "tie"}
|
|
|
|
def test_month_and_weekday_tables_are_complete(self):
|
|
assert len(C.MONTH_ABBR) == 12 and len(C.WEEKDAY_ABBR) == 7
|
|
|
|
|
|
class TestUnshareElementFonts:
|
|
"""Re-instantiated faces must match the ones they replace.
|
|
|
|
``unshare_element_fonts`` rebuilds a duplicate face purely so two elements
|
|
can be told apart by ``id()``. That is only safe while the rebuilt face
|
|
lays text out identically -- and PIL's two layout engines disagree on
|
|
fractional advances, which is why ``src.common.font_layout`` exists and
|
|
pins one. Rebuilding through bare ``ImageFont.truetype`` took the default
|
|
engine instead, so a re-instantiated face could measure differently from
|
|
the shared face it replaced.
|
|
"""
|
|
|
|
def _face(self, size=10):
|
|
from src.common.font_layout import load_truetype
|
|
path = os.path.join("assets", "fonts", "PressStart2P-Regular.ttf")
|
|
return load_truetype(path, size)
|
|
|
|
def test_rebuilt_face_goes_through_the_pinned_loader(self, log, monkeypatch):
|
|
"""Asserted on the loader, not on the resulting engine value.
|
|
|
|
PIL only selects Raqm when it is installed; where it is not, a bare
|
|
``ImageFont.truetype`` returns BASIC too, so comparing engine values
|
|
passes on those machines whether or not the pin is honoured -- this
|
|
test did exactly that before it was rewritten. Spying on the pinned
|
|
loader fails on every machine when the pin is bypassed, which is the
|
|
point: the cross-machine mismatch font_layout exists to prevent
|
|
cannot be reproduced on a Raqm-less runner.
|
|
"""
|
|
import src.common.font_layout as fl
|
|
|
|
# Build the face BEFORE patching: _face() loads through the same
|
|
# pinned loader, so patching first would let the fixture's own call
|
|
# satisfy the assertion and the test would pass either way.
|
|
shared = self._face()
|
|
|
|
calls = []
|
|
real = fl.load_truetype
|
|
|
|
def spy(font, size, **kwargs):
|
|
calls.append((font, size))
|
|
return real(font, size, **kwargs)
|
|
|
|
monkeypatch.setattr(fl, "load_truetype", spy)
|
|
fonts = {"score": shared, "time": shared}
|
|
C.unshare_element_fonts(log, fonts)
|
|
assert fonts["time"] is not shared, "the duplicate should have been rebuilt"
|
|
assert calls, "the rebuild must go through the pinned loader"
|
|
|
|
def test_rebuilt_face_keeps_the_shared_faces_layout_engine(self, log):
|
|
shared = self._face()
|
|
fonts = {"score": shared, "time": shared}
|
|
C.unshare_element_fonts(log, fonts)
|
|
assert fonts["time"].layout_engine == shared.layout_engine
|
|
|
|
def test_rebuilt_face_measures_identically(self, log):
|
|
shared = self._face()
|
|
fonts = {"score": shared, "time": shared}
|
|
C.unshare_element_fonts(log, fonts)
|
|
text = "88-88"
|
|
assert (fonts["time"].getlength(text)
|
|
== shared.getlength(text)), "metrics must not shift"
|
|
|
|
def test_distinct_faces_are_left_alone(self, log):
|
|
a, b = self._face(10), self._face(8)
|
|
fonts = {"score": a, "time": b}
|
|
C.unshare_element_fonts(log, fonts)
|
|
assert fonts["score"] is a and fonts["time"] is b
|
|
|
|
class TestSharedElementReaders:
|
|
"""The scoreboards' colour and offset reads now go through one
|
|
implementation.
|
|
|
|
There were two copies of the colour read and three of the offset read.
|
|
They had already drifted: the scroll-card renderer carries a comment
|
|
noting it used to ignore offsets its own schema advertised. Sharing one
|
|
reader is what lets a fix (or an alias, or a per-mode override) reach
|
|
the full-screen scorebug and the scroll card together.
|
|
"""
|
|
|
|
CONFIG = {
|
|
"customization": {
|
|
"score_text": {"text_color": [255, 200, 0]},
|
|
"status": {"text_color": "#00ff00"},
|
|
"layout": {"score": {"y_offset": -3}},
|
|
"modes": {
|
|
"live": {"score_text": {"text_color": [255, 0, 0]},
|
|
"layout": {"score": {"y_offset": 7}}},
|
|
},
|
|
},
|
|
}
|
|
|
|
def test_colour_reads_the_configured_value(self, log):
|
|
assert C.element_color(self.CONFIG, "score_text") == (255, 200, 0)
|
|
|
|
def test_hex_colours_are_still_accepted(self, log):
|
|
"""These readers have always taken '#RRGGBB' as well as [r, g, b],
|
|
so the shared one had to learn it rather than the callers losing it."""
|
|
assert C.element_color(self.CONFIG, "status") == (0, 255, 0)
|
|
|
|
def test_colour_resolves_through_an_alias(self, log):
|
|
"""The style block says status_text where this config says status."""
|
|
assert C.element_color(self.CONFIG, "status_text") == (0, 255, 0)
|
|
|
|
def test_a_mode_overrides_the_colour(self, log):
|
|
assert C.element_color(self.CONFIG, "score_text",
|
|
mode="live") == (255, 0, 0)
|
|
|
|
def test_an_unset_element_gets_the_default(self, log):
|
|
assert C.element_color(self.CONFIG, "nothing", (1, 2, 3)) == (1, 2, 3)
|
|
|
|
@pytest.mark.parametrize("config", [
|
|
None, {}, "nonsense", {"customization": "nonsense"},
|
|
{"customization": {"score_text": "nonsense"}},
|
|
{"customization": {"score_text": {"text_color": "not a colour"}}},
|
|
])
|
|
def test_a_hostile_config_gives_the_default(self, config, log):
|
|
assert C.element_color(config, "score_text", (9, 9, 9)) == (9, 9, 9)
|