diff --git a/test/test_display_controller.py b/test/test_display_controller.py index 782ce69e..8f89c31f 100644 --- a/test/test_display_controller.py +++ b/test/test_display_controller.py @@ -15,13 +15,6 @@ class TestDisplayControllerInitialization: assert test_display_controller.plugin_manager is not None assert test_display_controller.available_modes == [] - @pytest.mark.skip(reason="No assertions; init logic is covered by test_init_success and fixture setup") - def test_plugin_discovery_and_loading(self, test_display_controller): - """Test plugin discovery and loading during initialization.""" - pm = test_display_controller.plugin_manager - pm.discover_plugins.return_value = ["plugin1", "plugin2"] - pm.get_plugin.return_value = MagicMock() - class TestDisplayControllerModeRotation: """Test display mode rotation logic.""" diff --git a/test/test_display_manager.py b/test/test_display_manager.py index 63c49114..9c8b1468 100644 --- a/test/test_display_manager.py +++ b/test/test_display_manager.py @@ -1,6 +1,15 @@ +import os import pytest from unittest.mock import MagicMock, patch from PIL import ImageDraw + +# display_manager imports the hardware rgbmatrix module at import time unless +# EMULATOR=true. Use the emulator (same convention as +# test_display_dirty_tracking.py) so this file collects standalone instead of +# relying on collection order — the tests below patch RGBMatrix/ +# RGBMatrixOptions explicitly, so the underlying binding doesn't matter here. +os.environ.setdefault("EMULATOR", "true") + from src.display_manager import DisplayManager @pytest.fixture @@ -77,18 +86,26 @@ class TestDisplayManagerDrawing: assert dm.matrix.Clear.called def test_draw_text(self, test_config, mock_rgb_matrix): - """Test text drawing.""" + """Text drawn through draw_text must actually light pixels.""" + from PIL import Image, ImageDraw, ImageFont + import src.display_manager as dm_mod with patch.dict('os.environ', {'EMULATOR': 'false'}): - dm = DisplayManager(test_config) - - # Mock font - font = MagicMock() - - dm.draw_text("Test", 0, 0, font) - - # Verify draw_text was called (DisplayManager uses freetype/PIL) - # The actual implementation uses freetype or PIL, not graphics module - assert True # draw_text should execute without error + DisplayManager._instance = None + dm = DisplayManager(test_config, suppress_test_pattern=True) + # The fixture replaces the module's freetype with a MagicMock, + # which breaks draw_text's isinstance(font, freetype.Face) check + # (and silently swallows the draw). Give the mock a real class so + # isinstance works and the PIL path is taken. + dm_mod.freetype.Face = type("_FakeFace", (), {}) + # Start from a known-black canvas so the assertion below can only + # pass if draw_text itself lit something. + dm.image = Image.new('RGB', (dm.width, dm.height)) + dm.draw = ImageDraw.Draw(dm.image) + + dm.draw_text("Test", 0, 0, font=ImageFont.load_default()) + + assert dm.image.convert("L").getbbox() is not None, \ + "draw_text lit no pixels" def test_draw_image(self, test_config, mock_rgb_matrix): """Test image drawing.""" diff --git a/test/test_font_manager.py b/test/test_font_manager.py index 11240724..16b093a3 100644 --- a/test/test_font_manager.py +++ b/test/test_font_manager.py @@ -1,82 +1,126 @@ +""" +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 unittest.mock import patch +from PIL import ImageFont + from src.font_manager import FontManager + @pytest.fixture -def mock_freetype(): - """Mock freetype module.""" - with patch('src.font_manager.freetype') as mock_freetype: - yield mock_freetype +def fm(): + """A FontManager over the real assets/fonts catalog.""" + return FontManager({}) -class TestFontManager: - """Test FontManager functionality.""" - - def test_init(self, test_config, mock_freetype): - """Test FontManager initialization.""" - # Ensure BDF files exist check passes - with patch('os.path.exists', return_value=True): - fm = FontManager(test_config) - assert fm.config == test_config - assert hasattr(fm, 'font_cache') # FontManager uses font_cache, not fonts - - def test_get_font_success(self, test_config, mock_freetype): - """Test successful font loading.""" - with patch('os.path.exists', return_value=True), \ - patch('os.path.join', side_effect=lambda *args: "/".join(args)): - - fm = FontManager(test_config) - - # Request a font (get_font requires family and size_px) - # Font may be None if font file doesn't exist in test, that's ok - try: - font = fm.get_font("small", 12) # family and size_px required - # Just verify the method can be called - assert True # FontManager.get_font() executed - except (TypeError, AttributeError): - # If method signature doesn't match, that's ok for now - assert True - - def test_get_font_missing_file(self, test_config, mock_freetype): - """Test handling of missing font file.""" - with patch('os.path.exists', return_value=False): - fm = FontManager(test_config) - - # Request a font where file doesn't exist - # get_font requires family and size_px - try: - font = fm.get_font("small", 12) # family and size_px required - # Font may be None if file doesn't exist, that's ok - assert True # Method executed - except (TypeError, AttributeError): - assert True # Method signature may differ - - def test_get_font_invalid_name(self, test_config, mock_freetype): - """Test requesting invalid font name.""" - with patch('os.path.exists', return_value=True): - fm = FontManager(test_config) - - # Request unknown font (get_font requires family and size_px) - try: - font = fm.get_font("nonexistent_font", 12) # family and size_px required - # Font may be None for unknown font, that's ok - assert True # Method executed - except (TypeError, AttributeError): - assert True # Method signature may differ - def test_get_font_with_fallback(self, test_config, mock_freetype): - """Test font loading with fallback.""" - # FontManager.get_font() requires family and size_px - # This test verifies the method exists and can be called - fm = FontManager(test_config) - assert hasattr(fm, 'get_font') - assert True # Method exists, implementation may vary - - def test_load_custom_font(self, test_config, mock_freetype): - """Test loading a custom font file directly.""" - with patch('os.path.exists', return_value=True): - fm = FontManager(test_config) - - # FontManager uses add_font or get_font, not load_font - # Just verify the manager can handle font operations - # The actual method depends on implementation - assert hasattr(fm, 'get_font') or hasattr(fm, 'add_font') +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_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