mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-08 12:18:06 +00:00
Follow-ups from #441: secret-helper migration, ten more bug fixes, and coverage for every remaining untested module (#444)
* refactor(web): use canonical secret helpers in api_v3; make ConfigManager secret strip/merge array-aware
api_v3.py carried three inline nested copies of find_secret_fields/
separate_secrets (main-config save, plugin-config save, plugin-config
reset). They drifted from each other (one lacked isinstance guards) and
none supported the canonical module's array-item secrets
(accounts[].token). All three endpoints now import from
src/web_interface/secret_helpers.
Adopting the canonical behavior makes array-item secrets reachable, and
their parallel-placeholder shape ([{'token': ...}, {}] alongside the
regular list) was not survivable by ConfigManager's round-trip:
_strip_secrets_recursive dropped the whole key (losing the regular
fields from config.json) and _deep_merge replaced the regular list
wholesale on load. Both are now array-aware:
- strip removes the secret fields from each item and ALWAYS keeps the
list so indices survive for merge-on-load; whole-key secrets (scalar
lists, shape mismatches) still drop the key entirely — never leak.
- merge folds each secrets item into the config item at the same index,
skipping {} placeholders. The regular list's length is authoritative
in both directions: a user deleting an array item never has it
resurrected from a stale secrets entry (extras warn and are ignored).
api_v3's own deep_merge intentionally still replaces lists wholesale —
form posts carry complete arrays and index-merging would resurrect
deleted items; a comment now documents that.
Tests: the parity guard flips from 'exactly 3 inline copies' to 'zero,
and the canonical import must exist'; TestArraySecretStripAndMerge
covers the new strip/merge semantics incl. length-mismatch contracts;
new test_api_v3_secret_roundtrip.py drives all three endpoints through
a Flask client with a REAL ConfigManager+SchemaManager over tmp_path,
proving secrets land in config_secrets.json, config.json stays clean,
and a fresh load merges them back into the right array items.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh
* fix: repair broken helper paths across display, cache, odds, logging, resolver, repos, config, validator
Nine fixes for bugs surfaced while writing coverage for previously
untested modules (plus the bool-duration quirk pinned in PR #441):
- base_plugin.get_display_duration: exclude bools from both numeric
branches — display_duration=True no longer reads as a 1-second slot;
it falls through to config, then the 15.0 default.
- display_helper: draw_error_message/draw_no_data_message called
_draw_centered_text with the wrong arguments and crashed with
AttributeError — both now delegate to draw_centered_text.
draw_scorebug_layout drew status and clock at the same y, overprinting
each other — they now share one combined top line.
draw_ticker_layout drew its text starting at x=display_width (fully
off-canvas), returning a blank frame every time — now draws at x=0;
scroll_speed stays accepted-but-unused and is documented as such.
- api_helper.clear_cache guarded on a nonexistent CacheManager.clear()
method, silently never clearing anything; it now uses the real surface
(clear_cache/delete/list_cache_files) and no-ops safely otherwise.
- base_odds_manager._extract_espn_data raised AttributeError when ESPN
sent explicit JSON nulls ("homeTeamOdds": null) — every level now
null-safes with 'or {}'. format_odds_summary gated on
is_odds_available, which deliberately ignores money lines, so
ML-only odds formatted as "No odds available" — it now gates only on
empty/no_odds data and formats money lines.
- logging_config.ContextualFormatter mutated record.msg in place, so a
second handler prepended the context prefix twice; it now formats a
copy. log_error hardcoded exc_info=True and raised TypeError when the
caller passed exc_info — now kwargs.setdefault.
- dynamic_team_resolver wrote its "shared" class cache through self,
creating instance shadows — the cache was per-instance and every
scoreboard refetched rankings. Writes now go through the class.
- saved_repositories cleaned URLs with an unanchored .replace('.git','')
that mangled URLs merely containing '.git' (my.github.io -> myhub.io);
now strips only a trailing suffix. add/remove also roll back the
in-memory list when the save fails, so memory always matches disk.
- config_helper.merge_configs shallow-copied the base, aliasing every
un-overridden nested dict into the result — now deep-copies.
- startup_validator.validate_all accumulated errors/warnings across
calls — now resets both lists per run.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh
* test: cover the previously untested modules
Nine new suites plus an extension, asserting the Phase-1b fixed behavior
and pinning the quirks deliberately left alone:
- test_logging_config.py: formatters (JSON shape, no record mutation,
single prefix through two handlers), PluginLoggerAdapter precedence,
setup_logging handler hygiene and LEDMATRIX_DEBUG, log_error exc_info.
- test_startup_validator.py: exact messages, error-vs-warning split,
accessor split (load_config vs get_config), cache-dir branches with
os.access monkeypatched (root can write anything in CI), idempotence,
raise_on_errors classification precedence.
- test_config_helper.py (full): load/save round trips, dot-notation
get/set incl. silent-failure contract, post-fix no-aliasing merge,
schema validation branches, the '{id}_config' key pin, default-enabled
pin.
- test_saved_repositories.py: three load shapes, bare-list rewrite pin,
trailing-only .git strip (my.github.io regression), save-failure
rollback, type-classification case-sensitivity pin.
- test_api_helper.py: rate-limit math, cache-hit short circuit, ESPN
URL/key formats, exact User-Agent guard, retry adapter, post-fix
clear_cache against the real CacheManager surface, ttl-dropped pin.
- test_base_odds_manager.py: cache-key/URL construction, no_odds
sentinel round trip, stale-cache fallback, null-safe extraction,
ML-only formatting, is_odds_available truth table (ML-blind by
contract), config key/attr mismatch pin.
- test_dynamic_team_resolver.py: expansion/dedup/slicing, dropped
unknown-dynamic names (TOP_ substring hazard pinned), genuinely
shared class cache (second instance: zero HTTP), TTL expiry,
failure degradation without raising.
- test_display_helper.py (full): the fixed error/no-data renders,
combined scorebug top line, non-blank ticker with scroll_speed
no-op pin, composite upconversion, logo bleed positions, square
orientation pin.
- test_skin_runtime_cache.py: discovery-cache hit/invalidation
semantics (manifest mtime, .py edits pinned as non-invalidating),
sys.modules namespacing contract incl. bare-name restore and stdlib
shadowing, entry-module execute-once, API minor-version tolerance,
skin_matches_target table.
- test_sports_capabilities.py (extended): _draw_celebration_layout
executed for real (flash window, matrix-dims fallback, highlight
alternation, logo-failure isolation), _should_celebrate_for direct,
strict duration boundary, score_to_int edges, both-teams-score
precedence, expired-coalesce refire, disabled-win baseline
preservation, id-less prune.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh
* test: real schedule/dim coverage for DisplayController; fix two vacuous schedule tests
New test_display_controller_schedule.py drives _check_schedule and
_check_dim_schedule on a bare controller stub: same-day and
midnight-crossing windows with inclusive boundaries, global vs per-day vs
legacy-inferred modes (and dim's global-only default — no legacy
inference), per-day disabled days, invalid %H:%M fallbacks, unknown
timezone -> UTC, dim_brightness default 30, inactive-display short
circuit, and the _was_display_active/_was_dimmed transition flags.
test_display_controller.py's test_schedule_disabled and
test_active_hours patched config_service.get_config — which
_check_schedule never reads — so both asserted the init-default value
and could not fail. Rewritten on the test_inactive_hours pattern
(inject controller.config['schedule'], reset the minute gate, flip the
flag to the opposite state first so the assertion has teeth).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh
* ci: raise coverage floor to 48%
Measured 50% with the new suites in place (was 47% baseline when the
gate was introduced at 45); floor stays two points under measured.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh
* fix: address CodeQL alert and review findings
- config_manager: the "secrets list longer than config list" warning now
interpolates only config-side data (no key name or secrets-derived
values), resolving the CodeQL clear-text-logging alert.
- base_plugin: validate_config rejects bool display_duration, matching
get_display_duration (bool is an int subclass and would otherwise pass
as a positive number).
- config_helper: merge_configs deep-copies override values in the
non-recursive branch so mutating the merged result cannot reach back
into override_config.
- saved_repositories: saves are atomic (temp file + fsync + os.replace),
so a failed write can no longer truncate saved_repositories.json.
- tests: regression cases for each fix, plus a pin that whole-item
array secrets (key[] + key[].field both marked) strip to empty {}
skeletons — no secret values can reach config.json.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh
---------
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,307 @@
|
||||
"""Tests for src/common/display_helper.py (DisplayHelper).
|
||||
|
||||
Pure-PIL tests, no hardware or mocks required. Pixel assertions rely on
|
||||
getbbox()/getpixel() rather than exact text pixel counts, because the
|
||||
default-font metrics vary across Pillow versions.
|
||||
|
||||
These tests pin the FIXED behaviors on this branch:
|
||||
- draw_error_message / draw_no_data_message return a rendered image
|
||||
(they previously crashed with AttributeError),
|
||||
- draw_scorebug_layout draws period/status/clock as one combined top
|
||||
line (previously overprinted at the same y),
|
||||
- draw_ticker_layout draws at x=0 (previously started at
|
||||
x=display_width, i.e. entirely off-canvas -> blank frames).
|
||||
"""
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
from src.common.display_helper import DisplayHelper
|
||||
|
||||
|
||||
def default_font():
|
||||
return ImageFont.load_default()
|
||||
|
||||
|
||||
def make_helper(width=128, height=32):
|
||||
return DisplayHelper(width, height)
|
||||
|
||||
|
||||
class TestCreateBaseImage:
|
||||
def test_default_is_black_rgb_display_sized(self):
|
||||
helper = make_helper()
|
||||
img = helper.create_base_image()
|
||||
assert img.size == (128, 32)
|
||||
assert img.mode == 'RGB'
|
||||
assert img.getpixel((0, 0)) == (0, 0, 0)
|
||||
assert img.getpixel((127, 31)) == (0, 0, 0)
|
||||
# Entirely black -> no bounding box in luminance
|
||||
assert img.convert('L').getbbox() is None
|
||||
|
||||
def test_custom_background_color(self):
|
||||
helper = make_helper()
|
||||
img = helper.create_base_image(background_color=(10, 20, 30))
|
||||
assert img.getpixel((0, 0)) == (10, 20, 30)
|
||||
assert img.getpixel((64, 16)) == (10, 20, 30)
|
||||
|
||||
def test_mode_rgba_is_honored(self):
|
||||
helper = make_helper()
|
||||
img = helper.create_base_image(mode='RGBA')
|
||||
assert img.mode == 'RGBA'
|
||||
assert img.size == (128, 32)
|
||||
|
||||
|
||||
class TestCreateOverlay:
|
||||
def test_overlay_is_transparent_rgba(self):
|
||||
helper = make_helper()
|
||||
overlay = helper.create_overlay()
|
||||
assert overlay.mode == 'RGBA'
|
||||
assert overlay.size == (128, 32)
|
||||
assert overlay.getpixel((0, 0)) == (0, 0, 0, 0)
|
||||
assert overlay.getpixel((127, 31)) == (0, 0, 0, 0)
|
||||
|
||||
|
||||
class TestCompositeImages:
|
||||
def test_rgb_inputs_are_upconverted_and_result_is_rgba(self):
|
||||
helper = make_helper()
|
||||
base = Image.new('RGB', (128, 32), (0, 0, 0))
|
||||
overlay = Image.new('RGB', (128, 32), (255, 0, 0))
|
||||
result = helper.composite_images(base, overlay)
|
||||
assert result.mode == 'RGBA'
|
||||
assert result.size == base.size
|
||||
# RGB->RGBA conversion yields a fully opaque overlay
|
||||
assert result.getpixel((0, 0)) == (255, 0, 0, 255)
|
||||
|
||||
def test_transparent_overlay_leaves_base_visible(self):
|
||||
helper = make_helper()
|
||||
base = Image.new('RGB', (128, 32), (5, 6, 7))
|
||||
overlay = helper.create_overlay()
|
||||
result = helper.composite_images(base, overlay)
|
||||
assert result.mode == 'RGBA'
|
||||
assert result.getpixel((64, 16)) == (5, 6, 7, 255)
|
||||
|
||||
|
||||
class TestScorebugLayout:
|
||||
def test_full_game_data_renders(self):
|
||||
helper = make_helper()
|
||||
font = default_font()
|
||||
fonts = {'time': font, 'status': font, 'score': font, 'team': font}
|
||||
game_data = {
|
||||
'home_score': 3, 'away_score': 2,
|
||||
'home_abbr': 'NYY', 'away_abbr': 'BOS',
|
||||
'status_text': 'LIVE', 'period_text': 'T9', 'clock': '2:30',
|
||||
}
|
||||
img = helper.draw_scorebug_layout(game_data, fonts)
|
||||
assert img.mode == 'RGB'
|
||||
assert img.size == (128, 32)
|
||||
assert img.convert('L').getbbox() is not None
|
||||
|
||||
def test_empty_game_data_uses_defaults_without_raising(self):
|
||||
helper = make_helper()
|
||||
font = default_font()
|
||||
fonts = {'time': font, 'status': font, 'score': font, 'team': font}
|
||||
img = helper.draw_scorebug_layout({}, fonts)
|
||||
assert img.mode == 'RGB'
|
||||
assert img.size == (128, 32)
|
||||
# Defaults '0'/'HOME'/'AWAY' actually render something
|
||||
assert img.convert('L').getbbox() is not None
|
||||
|
||||
def test_empty_fonts_dict_falls_back_to_default_font(self):
|
||||
# Pin: fonts={} must not raise — PIL falls back to the default
|
||||
# font when font=None is passed through.
|
||||
helper = make_helper()
|
||||
img = helper.draw_scorebug_layout(
|
||||
{'status_text': 'FINAL', 'period_text': 'Q4', 'clock': '0:00'}, {})
|
||||
assert img.size == (128, 32)
|
||||
assert img.convert('L').getbbox() is not None
|
||||
|
||||
def test_top_line_is_one_combined_centered_draw(self):
|
||||
# FIXED behavior: period/status/clock are joined into a single
|
||||
# top line drawn once at y=1 instead of three overprinted draws.
|
||||
helper = make_helper()
|
||||
calls = []
|
||||
original = helper._draw_centered_text
|
||||
|
||||
def spy(draw, text, font, y_position):
|
||||
calls.append({'text': text, 'y_position': y_position})
|
||||
original(draw, text, font, y_position)
|
||||
|
||||
helper._draw_centered_text = spy
|
||||
font = default_font()
|
||||
fonts = {'time': font, 'status': font, 'score': font, 'team': font}
|
||||
helper.draw_scorebug_layout(
|
||||
{'period_text': 'Q4', 'status_text': 'LIVE', 'clock': '2:30'},
|
||||
fonts)
|
||||
|
||||
top_calls = [c for c in calls if c['y_position'] == 1]
|
||||
assert len(top_calls) == 1
|
||||
text = top_calls[0]['text']
|
||||
assert 'Q4' in text
|
||||
assert 'LIVE' in text
|
||||
assert '2:30' in text
|
||||
|
||||
def test_no_top_line_when_all_parts_empty(self):
|
||||
helper = make_helper()
|
||||
calls = []
|
||||
original = helper._draw_centered_text
|
||||
|
||||
def spy(draw, text, font, y_position):
|
||||
calls.append(y_position)
|
||||
original(draw, text, font, y_position)
|
||||
|
||||
helper._draw_centered_text = spy
|
||||
font = default_font()
|
||||
helper.draw_scorebug_layout({}, {'score': font, 'team': font})
|
||||
assert 1 not in calls # no combined top line drawn
|
||||
|
||||
def test_logo_positions_bleed_off_edges(self):
|
||||
# Home logo pastes at x = width - logo.width + 10 (right edge,
|
||||
# bleeding off-screen right); away at x = -10 (bleeding left).
|
||||
helper = make_helper()
|
||||
home_logo = Image.new('RGBA', (20, 20), (0, 0, 255, 255)) # blue
|
||||
away_logo = Image.new('RGBA', (20, 20), (255, 0, 0, 255)) # red
|
||||
# Empty abbrs/status so text can't land on the probed pixels.
|
||||
game_data = {'home_abbr': '', 'away_abbr': ''}
|
||||
font = default_font()
|
||||
img = helper.draw_scorebug_layout(game_data, {'score': font},
|
||||
home_logo=home_logo,
|
||||
away_logo=away_logo)
|
||||
# center_y = 16; logos span y 6..25 -> probe y=16 at both edges.
|
||||
assert img.getpixel((0, 16)) == (255, 0, 0) # away (left edge)
|
||||
assert img.getpixel((127, 16)) == (0, 0, 255) # home (right edge)
|
||||
# And the off-screen parts are truly clipped: image is still 128 wide
|
||||
assert img.size == (128, 32)
|
||||
|
||||
|
||||
class TestTickerLayout:
|
||||
def test_frame_is_not_blank(self):
|
||||
# FIXED behavior: text now starts at x=0. Previously it was drawn
|
||||
# at x=display_width, entirely off-canvas, so frames were blank.
|
||||
helper = make_helper()
|
||||
img = helper.draw_ticker_layout('HELLO WORLD', default_font())
|
||||
assert img.size == (128, 32)
|
||||
assert img.mode == 'RGB'
|
||||
assert img.convert('L').getbbox() is not None
|
||||
|
||||
def test_text_starts_at_left_edge(self):
|
||||
helper = make_helper()
|
||||
img = helper.draw_ticker_layout('HELLO', default_font())
|
||||
bbox = img.convert('L').getbbox()
|
||||
assert bbox is not None
|
||||
# Text is positioned at x=0 (outline extends 1px left, clipped),
|
||||
# so ink begins hugging the left edge. Allow a couple of pixels of
|
||||
# slack for font-dependent left-side bearing.
|
||||
assert bbox[0] <= 2
|
||||
|
||||
def test_scroll_speed_does_not_affect_frame(self):
|
||||
# Pin: scroll_speed is accepted for API compatibility only.
|
||||
helper = make_helper()
|
||||
font = default_font()
|
||||
img1 = helper.draw_ticker_layout('SCROLLING', font, scroll_speed=1)
|
||||
img5 = helper.draw_ticker_layout('SCROLLING', font, scroll_speed=5)
|
||||
assert img1.tobytes() == img5.tobytes()
|
||||
|
||||
def test_custom_colors(self):
|
||||
helper = make_helper()
|
||||
img = helper.draw_ticker_layout('X', default_font(),
|
||||
background_color=(0, 0, 40),
|
||||
text_color=(0, 255, 0))
|
||||
assert img.getpixel((127, 0)) == (0, 0, 40) # background corner
|
||||
colors = {img.getpixel((x, y))
|
||||
for x in range(img.width) for y in range(img.height)}
|
||||
# Text color appears somewhere (anti-aliasing may blend it, so
|
||||
# check for a green-dominant pixel rather than the exact color).
|
||||
assert any(g > 150 and r < 100 for (r, g, b) in colors)
|
||||
|
||||
|
||||
class TestCenteredText:
|
||||
def test_renders_centered_text_on_background(self):
|
||||
helper = make_helper()
|
||||
img = helper.draw_centered_text('HI', default_font(),
|
||||
background_color=(0, 0, 60),
|
||||
text_color=(255, 255, 0))
|
||||
assert img.size == (128, 32)
|
||||
assert img.convert('L').getbbox() is not None
|
||||
# Corners stay pure background
|
||||
assert img.getpixel((0, 0)) == (0, 0, 60)
|
||||
assert img.getpixel((127, 0)) == (0, 0, 60)
|
||||
assert img.getpixel((0, 31)) == (0, 0, 60)
|
||||
assert img.getpixel((127, 31)) == (0, 0, 60)
|
||||
|
||||
|
||||
class TestErrorAndNoDataMessages:
|
||||
def test_draw_error_message_returns_rendered_image(self):
|
||||
# FIXED behavior: used to crash with AttributeError; now returns
|
||||
# a rendered image on a dark red background.
|
||||
helper = make_helper()
|
||||
img = helper.draw_error_message('Boom')
|
||||
assert img.size == (128, 32)
|
||||
assert img.mode == 'RGB'
|
||||
assert img.convert('L').getbbox() is not None
|
||||
assert img.getpixel((0, 0)) == (50, 0, 0) # dark red background
|
||||
|
||||
def test_draw_error_message_default_text(self):
|
||||
helper = make_helper()
|
||||
img = helper.draw_error_message()
|
||||
assert img.size == (128, 32)
|
||||
assert img.getpixel((127, 31)) == (50, 0, 0)
|
||||
|
||||
def test_draw_no_data_message_returns_rendered_image(self):
|
||||
helper = make_helper()
|
||||
img = helper.draw_no_data_message()
|
||||
assert img.size == (128, 32)
|
||||
assert img.mode == 'RGB'
|
||||
assert img.convert('L').getbbox() is not None
|
||||
assert img.getpixel((0, 0)) == (0, 0, 0) # black background
|
||||
|
||||
|
||||
class TestDrawTextWithOutline:
|
||||
def test_fill_color_appears_in_output(self):
|
||||
helper = make_helper()
|
||||
img = Image.new('RGB', (40, 20), (0, 0, 255))
|
||||
draw = ImageDraw.Draw(img)
|
||||
helper._draw_text_with_outline(draw, 'X', (5, 2), default_font(),
|
||||
fill=(255, 0, 0))
|
||||
pixels = {img.getpixel((x, y))
|
||||
for x in range(img.width) for y in range(img.height)}
|
||||
# Anti-aliased fonts blend edge pixels, so look for red-dominant
|
||||
# (fill) and near-black (outline) pixels rather than exact colors.
|
||||
assert any(r > 150 and g < 50 for (r, g, b) in pixels) # fill
|
||||
assert any(max(p) < 80 for p in pixels) # outline
|
||||
|
||||
def test_default_fill_is_white(self):
|
||||
helper = make_helper()
|
||||
img = Image.new('RGB', (40, 20), (0, 0, 255))
|
||||
draw = ImageDraw.Draw(img)
|
||||
helper._draw_text_with_outline(draw, 'X', (5, 2), default_font())
|
||||
pixels = {img.getpixel((x, y))
|
||||
for x in range(img.width) for y in range(img.height)}
|
||||
# White-dominant pixel present (exact white may be anti-aliased)
|
||||
assert any(r > 200 and g > 200 for (r, g, b) in pixels)
|
||||
|
||||
|
||||
class TestOrientationAndDimensions:
|
||||
def test_landscape_display(self):
|
||||
helper = DisplayHelper(128, 32)
|
||||
assert helper.is_landscape() is True
|
||||
assert helper.is_portrait() is False
|
||||
|
||||
def test_portrait_display(self):
|
||||
helper = DisplayHelper(32, 128)
|
||||
assert helper.is_portrait() is True
|
||||
assert helper.is_landscape() is False
|
||||
|
||||
def test_square_display_is_neither(self):
|
||||
# Pin: a square display is neither portrait nor landscape.
|
||||
helper = DisplayHelper(64, 64)
|
||||
assert helper.is_portrait() is False
|
||||
assert helper.is_landscape() is False
|
||||
|
||||
def test_get_center_position(self):
|
||||
assert DisplayHelper(128, 32).get_center_position() == (64, 16)
|
||||
|
||||
def test_get_center_position_floors_odd_dimensions(self):
|
||||
assert DisplayHelper(65, 33).get_center_position() == (32, 16)
|
||||
|
||||
def test_get_display_dimensions(self):
|
||||
assert DisplayHelper(128, 32).get_display_dimensions() == (128, 32)
|
||||
assert DisplayHelper(64, 64).get_display_dimensions() == (64, 64)
|
||||
Reference in New Issue
Block a user