mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +00:00
* fix(errors): stop affected_plugins growing without bound Each repeat of an error pattern appended every plugin in the time window to the pattern's list again, so a plugin failing in a loop grew the display process's memory without limit: 3,000 errors from three plugins reached 2.5 million entries. Keep the list unique. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(fonts): load a BDF font at its native size instead of PIL's default FreeType rejects any size but a BDF strike's own, and FontManager answered that with ImageFont.load_default() -- a different typeface -- so 5x7.bdf requested at 8 or 10px rendered as PIL's default font. Retry at the native strike, as element_style already does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): plugin toggle failures no longer claim "operation in progress" Every exception in POST /plugins/toggle was mapped to PLUGIN_OPERATION_CONFLICT, so any failure told the user "A plugin operation is already in progress". Report the failure as what it is, and record the plugin id in the operation history for form posts too (it read a `data` variable that only the JSON path set). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): route plugin card clicks through handlePluginAction The document-level delegation checked `typeof handlePluginAction`, which is scoped inside the plugin-manager IIFE and so never visible to it. Every card click took a copied fallback that stopped propagation (the grid's own listener never ran), confirmed an uninstall twice, and sent Starlark app uninstalls to POST /plugins/uninstall instead of DELETE /starlark/apps/<id>. Expose the handler on window and delegate to it. Also run every test/js/unit suite under pytest: they need only node, but CI ran one of the eight. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(display): apply Rotation durations, WiFi messages and Vegas settings Three settings the web UI saves never reached the display: - Rotation & Durations: display.display_durations was never read. Every plugin inherits get_display_duration() and the plugin was asked first. A saved value now wins. The page shows unsaved screens blank with the plugin's own duration as a placeholder, and saving a blank removes the override, so one save no longer pins every screen. - WiFi status overlay: the controller looked for wifi_status.json one directory above the repo. Both sides now use wifi_manager.get_wifi_status_path(). The message is written by rename so the display never reads it half-written, and the resumed plugin redraws the whole panel afterwards. - Vegas: nothing called coordinator.update_config(), so saved Vegas settings never reached a running scroll. They are now queued when display.vegas_scroll changes, and applied while Vegas is stopped too, so a disable then re-enable works. The follower's scroll-speed default (75) now matches VegasModeConfig's (50). Also throttles Vegas's per-frame live-priority scan to 4Hz. It cost 139us per frame on a Pi 4 with two scoreboards (1.7% of a 125fps frame) and grows with each plugin. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: keep affected_plugins order when serialized; guard non-Element targets ErrorPattern.to_dict() ran the now-ordered list through set(), so get_error_summary() listed plugins in an unstable order. The document-level card-action listener called event.target.closest() without checking the target is an Element. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
135 lines
5.3 KiB
Python
135 lines
5.3 KiB
Python
"""
|
|
Tests for src/font_manager.py — FontManager loading, caching, fallback,
|
|
and BDF handling, exercised against the real bundled fonts in assets/fonts.
|
|
|
|
This file replaces an earlier version whose tests were try/except blocks
|
|
ending in `assert True` — they executed the code but could not fail. Every
|
|
test here asserts observable behavior: returned font types, cache identity,
|
|
fallback selection, and BDF native-size reading.
|
|
"""
|
|
|
|
import freetype
|
|
import pytest
|
|
from PIL import ImageFont
|
|
|
|
from src.font_manager import FontManager
|
|
|
|
|
|
@pytest.fixture
|
|
def fm():
|
|
"""A FontManager over the real assets/fonts catalog."""
|
|
return FontManager({})
|
|
|
|
|
|
class TestCatalog:
|
|
def test_bundled_common_fonts_are_registered(self, fm):
|
|
# These aliases are hardcoded in FontManager.common_fonts and the
|
|
# files ship in assets/fonts — all three must resolve.
|
|
for family in ("press_start", "four_by_six", "five_by_seven"):
|
|
assert family in fm.font_catalog, f"{family} missing from catalog"
|
|
|
|
def test_catalog_families_are_lowercase_filenames(self, fm):
|
|
# _scan_fonts_directory lowercases the filename stem.
|
|
assert all(name == name.lower() for name in fm.font_catalog)
|
|
|
|
|
|
class TestGetFont:
|
|
def test_ttf_family_returns_usable_pil_font(self, fm):
|
|
font = fm.get_font("press_start", 8)
|
|
assert isinstance(font, ImageFont.FreeTypeFont)
|
|
# Usable: it can measure text.
|
|
bbox = font.getbbox("Hi")
|
|
assert bbox[2] > bbox[0]
|
|
|
|
def test_bdf_family_returns_freetype_face(self, fm):
|
|
font = fm.get_font("five_by_seven", 7)
|
|
assert isinstance(font, freetype.Face)
|
|
|
|
def test_bdf_at_a_size_it_lacks_uses_its_native_strike(self, fm):
|
|
# FreeType rejects any size but the strike's own. This used to hand
|
|
# back PIL's default font, a different typeface, for 5x7 at 8 or 10.
|
|
for size in (8, 10):
|
|
font = fm.get_font("five_by_seven", size)
|
|
assert isinstance(font, freetype.Face), size
|
|
assert font.size.y_ppem == 7
|
|
|
|
def test_repeat_call_returns_cached_identity(self, fm):
|
|
first = fm.get_font("press_start", 8)
|
|
hits_before = fm.performance_stats["cache_hits"]
|
|
second = fm.get_font("press_start", 8)
|
|
assert second is first
|
|
assert fm.performance_stats["cache_hits"] == hits_before + 1
|
|
|
|
def test_different_sizes_get_distinct_cache_entries(self, fm):
|
|
small = fm.get_font("press_start", 8)
|
|
large = fm.get_font("press_start", 16)
|
|
assert small is not large
|
|
assert "press_start_8" in fm.font_cache
|
|
assert "press_start_16" in fm.font_cache
|
|
|
|
def test_unknown_family_falls_back_to_default_without_raising(self, fm):
|
|
failed_before = fm.performance_stats["failed_loads"]
|
|
font = fm.get_font("no-such-family", 10)
|
|
# The documented fallback is PIL's default font (whose concrete type
|
|
# varies across Pillow versions), recorded as a failed load. It must
|
|
# still be usable for measurement.
|
|
assert type(font) is type(ImageFont.load_default())
|
|
assert font.getbbox("Hi")[2] > 0
|
|
assert fm.performance_stats["failed_loads"] == failed_before + 1
|
|
|
|
def test_corrupt_font_file_falls_back_to_default(self, fm, tmp_path):
|
|
bad = tmp_path / "broken.ttf"
|
|
bad.write_text("this is not a font file")
|
|
fm.font_catalog["broken"] = str(bad)
|
|
failed_before = fm.performance_stats["failed_loads"]
|
|
font = fm.get_font("broken", 10)
|
|
assert type(font) is type(ImageFont.load_default())
|
|
assert font.getbbox("Hi")[2] > 0
|
|
assert fm.performance_stats["failed_loads"] == failed_before + 1
|
|
|
|
|
|
class TestBdfNativeSize:
|
|
def test_five_by_seven_reports_native_height(self, fm):
|
|
# 5x7.bdf declares a 7px strike; requesting other sizes still renders
|
|
# the native size, so callers need this to know the truth.
|
|
assert fm.get_native_bdf_size("five_by_seven") == 7
|
|
|
|
def test_ttf_family_has_no_native_size(self, fm):
|
|
assert fm.get_native_bdf_size("press_start") is None
|
|
|
|
def test_unknown_family_has_no_native_size(self, fm):
|
|
assert fm.get_native_bdf_size("no-such-family") is None
|
|
|
|
|
|
class TestMeasureText:
|
|
def test_ttf_measurement_is_positive_and_cached(self, fm):
|
|
font = fm.get_font("press_start", 8)
|
|
width, height, baseline = fm.measure_text("SCORE", font)
|
|
assert width > 0 and height > 0
|
|
# Cached: same result object path on second call.
|
|
assert fm.measure_text("SCORE", font) == (width, height, baseline)
|
|
assert ("SCORE", id(font)) in fm.metrics_cache
|
|
|
|
def test_longer_text_measures_wider(self, fm):
|
|
font = fm.get_font("press_start", 8)
|
|
short, _, _ = fm.measure_text("AB", font)
|
|
long, _, _ = fm.measure_text("ABCD", font)
|
|
assert long > short
|
|
|
|
|
|
class TestCacheLifecycle:
|
|
def test_clear_cache_empties_both_caches(self, fm):
|
|
font = fm.get_font("press_start", 8)
|
|
fm.measure_text("X", font)
|
|
assert fm.font_cache and fm.metrics_cache
|
|
fm.clear_cache()
|
|
assert not fm.font_cache
|
|
assert not fm.metrics_cache
|
|
|
|
def test_reload_config_bumps_generation_and_clears(self, fm):
|
|
fm.get_font("press_start", 8)
|
|
gen_before = fm.cache_generation
|
|
fm.reload_config({})
|
|
assert fm.cache_generation == gen_before + 1
|
|
assert not fm.font_cache
|