import json import time from pathlib import Path from types import SimpleNamespace import pytest from PIL import Image import app.display.text_renderer as text_renderer import app.display.matrix_driver as matrix_driver_module from app.config.store import ConfigStore from app.display.diagnostics import render_diagnostic from app.display.low_voltage_indicator import ( BACKGROUND as LOW_VOLTAGE_BACKGROUND, RED as LOW_VOLTAGE_RED, render_low_voltage_indicator, ) from app.display.matrix_driver import ( MatrixDisplayDriver, get_default_driver_options, get_matrix_driver_options, ) from app.display.service import AnimationPlaybackConflictError, DisplayService from app.display.ota_indicator import ( OTA_CANVAS_SIZE, OTA_CONTENT_OFFSET, OTA_CONTENT_SIZE, render_ota_indicator, ) from app.display.startup_indicator import BACKGROUND, PINK, render_startup_smile from app.display.text_renderer import FontFace, TextOptions, render_text, render_text_layer from app.display.transforms import apply_orientation, apply_row_bit_order, get_row_bit_order from app.power.protection import LowVoltageProtectionDirective def protection_directive(sequence, mode, brightness_limit=None, *, enabled=True): return LowVoltageProtectionDirective( sequence=sequence, enabled=enabled, mode=mode, brightness_limit_percent=brightness_limit, reading_stale=False, ) def test_ota_indicator_uses_40_percent_and_restores_user_output(tmp_path): store = ConfigStore(project_root=tmp_path) store.update({"brightness": 27}) service = DisplayService(store, driver_kind="mock") status_path = store.runtime_dir / "ota-status.json" status_path.write_text( json.dumps({"active": True, "job": {"percent": 42, "message": "正在测试"}}), encoding="utf-8", ) try: service.fill((1, 2, 3)) user_frame = service.get_current_frame() service.start_ota_indicator(status_path) status = service.get_status() assert status["state"]["mode"] == "ota_update_indicator" assert status["state"]["effective_brightness"] == 40 assert status["state"]["ota_update"]["active"] is True assert status["state"]["ota_update"]["percent"] == 42 service.stop_ota_indicator() restored = service.get_status() assert restored["state"]["mode"] == "fill" assert restored["state"]["effective_brightness"] == 27 assert service.get_current_frame().tobytes() == user_frame.tobytes() finally: service.close() def test_ota_indicator_scales_entire_frame_to_centered_90_percent_area(): frame = render_ota_indicator(42, "正在测试") assert frame.size == (OTA_CANVAS_SIZE, OTA_CANVAS_SIZE) assert OTA_CONTENT_SIZE == 58 assert OTA_CONTENT_OFFSET == 3 assert OTA_CONTENT_OFFSET * 2 + OTA_CONTENT_SIZE == OTA_CANVAS_SIZE assert frame.crop((0, 0, OTA_CANVAS_SIZE, OTA_CONTENT_OFFSET)).getbbox() is None assert frame.crop((0, OTA_CANVAS_SIZE - OTA_CONTENT_OFFSET, OTA_CANVAS_SIZE, OTA_CANVAS_SIZE)).getbbox() is None assert frame.crop((0, 0, OTA_CONTENT_OFFSET, OTA_CANVAS_SIZE)).getbbox() is None assert frame.crop((OTA_CANVAS_SIZE - OTA_CONTENT_OFFSET, 0, OTA_CANVAS_SIZE, OTA_CANVAS_SIZE)).getbbox() is None def test_ota_indicator_keeps_labels_complete_and_inside_the_scaled_content(): frame = render_ota_indicator(42, "正在测试") label = frame.crop((20, 34, 45, 45)) assert label.getbbox() is not None assert all(channel in {0, 245, 250} for channel in label.tobytes()) # OTA uses three separated, four-way-connected glyphs after the shrink. lit = { (x, y) for y in range(label.height) for x in range(label.width) if label.getpixel((x, y)) != (0, 0, 0) } components = 0 while lit: components += 1 pending = [lit.pop()] while pending: x, y = pending.pop() for neighbour in ((x - 1, y), (x + 1, y), (x, y - 1), (x, y + 1)): if neighbour in lit: lit.remove(neighbour) pending.append(neighbour) assert components == 3 percent = frame.crop((20, 53, 45, OTA_CANVAS_SIZE - OTA_CONTENT_OFFSET)) assert percent.getbbox() is not None assert frame.crop((0, OTA_CANVAS_SIZE - OTA_CONTENT_OFFSET, OTA_CANVAS_SIZE, OTA_CANVAS_SIZE)).getbbox() is None def test_critical_low_voltage_remains_above_ota_indicator(tmp_path): store = ConfigStore(project_root=tmp_path) service = DisplayService(store, driver_kind="mock") status_path = store.runtime_dir / "ota-status.json" status_path.write_text(json.dumps({"active": True, "job": {"percent": 10}}), encoding="utf-8") try: service.start_ota_indicator(status_path) service.apply_power_protection(protection_directive(1, "critical", 35)) status = service.get_status() assert status["state"]["mode"] == "low_voltage_indicator" assert status["state"]["effective_brightness"] == 35 assert status["state"]["ota_update"]["active"] is True finally: service.close() def test_display_service_writes_mock_frame(tmp_path): store = ConfigStore(project_root=tmp_path) service = DisplayService(store, driver_kind="mock") service.fill((255, 0, 0)) assert store.config["last_mode"] == "fill" assert (tmp_path / "data" / "runtime" / "last_frame.png").exists() def test_current_frame_snapshot_is_logical_and_side_effect_free(tmp_path): store = ConfigStore(project_root=tmp_path) service = DisplayService(store, driver_kind="mock") image = Image.new("RGB", (64, 64), (0, 0, 0)) image.putpixel((2, 7), (12, 34, 56)) service.show_image(image, mode="composition") service.set_orientation(90) before = service.get_status()["state"].copy() snapshot = service.get_current_frame() snapshot.putpixel((2, 7), (255, 255, 255)) assert snapshot.mode == "RGB" assert snapshot.size == (64, 64) assert service.get_current_frame().getpixel((2, 7)) == (12, 34, 56) assert service.get_status()["state"] == before def test_current_frame_snapshot_uses_active_startup_frame(tmp_path): store = ConfigStore(project_root=tmp_path) service = DisplayService(store, driver_kind="mock", startup_boot_id="snapshot-boot") try: assert service.start_startup_indicator() is True before = service.get_status()["state"].copy() snapshot = service.get_current_frame() assert snapshot.tobytes() == render_startup_smile().tobytes() assert service.get_status()["state"] == before finally: service.close() def test_display_test_uses_temporary_brightness_and_restores_user_output(tmp_path): store = ConfigStore(project_root=tmp_path) store.update({"brightness": 37}) service = DisplayService(store, driver_kind="mock") user_frame = Image.new("RGB", (64, 64), (12, 34, 56)) service.show_image(user_frame, mode="composition") revision = service.get_status()["state"]["revision"] started = service.start_display_test_fill((255, 0, 0)) state = service.get_status()["state"] assert started == {"active": True, "color": "#FF0000", "brightness": 50} assert state["mode"] == "display_test:fill" assert state["effective_brightness"] == 50 assert state["revision"] == revision + 1 assert service.get_current_frame().getpixel((0, 0)) == (255, 0, 0) assert store.config["brightness"] == 37 assert store.config["last_mode"] == "composition" service.set_display_test_brightness(72) switched = service.start_display_test_fill((0, 0, 255)) assert switched["brightness"] == 72 assert service.get_status()["state"]["effective_brightness"] == 72 assert store.config["brightness"] == 37 service.set_brightness(29) service.set_orientation(90) assert service.get_status()["state"]["mode"] == "display_test:fill" assert service.stop_display_test() == { "active": False, "color": None, "brightness": None, } restored = service.get_status()["state"] assert restored["mode"] == "composition" assert restored["brightness"] == 29 assert restored["effective_brightness"] == 29 assert restored["orientation"] == 90 assert service.get_current_frame().tobytes() == user_frame.tobytes() def test_display_test_obeys_low_voltage_and_normal_content_cancels_it(tmp_path): store = ConfigStore(project_root=tmp_path) service = DisplayService(store, driver_kind="mock") service.start_display_test_fill((255, 0, 0)) service.set_display_test_brightness(80) service.apply_power_protection(protection_directive(1, "limiting", 60)) assert service.get_status()["state"]["effective_brightness"] == 60 assert service.get_current_frame().getpixel((0, 0)) == (255, 0, 0) service.apply_power_protection(protection_directive(2, "critical", 35)) state = service.get_status()["state"] assert state["mode"] == "low_voltage_indicator" assert state["effective_brightness"] == 35 assert state["display_test"] == { "active": True, "color": "#FF0000", "brightness": 80, } service.apply_power_protection(protection_directive(3, "inactive")) assert service.get_status()["state"]["mode"] == "display_test:fill" service.fill((0, 255, 0)) state = service.get_status()["state"] assert state["display_test"]["active"] is False assert state["mode"] == "fill" assert service.get_current_frame().getpixel((0, 0)) == (0, 255, 0) def test_display_test_brightness_requires_an_active_session(tmp_path): service = DisplayService(ConfigStore(project_root=tmp_path), driver_kind="mock") with pytest.raises(RuntimeError, match="not active"): service.set_display_test_brightness(50) for value in (0, 101, True, 1.5): with pytest.raises(ValueError, match="1..100"): service.set_display_test_brightness(value) def test_startup_smile_frames_have_fixed_pixel_art_geometry(): open_frame = render_startup_smile() closed_frame = render_startup_smile(eyes_closed=True) assert open_frame.mode == "RGB" assert open_frame.size == (64, 64) assert open_frame.getpixel((0, 0)) == BACKGROUND assert open_frame.getpixel((16, 18)) == PINK assert open_frame.getpixel((40, 18)) == PINK assert open_frame.getpixel((30, 50)) == PINK assert closed_frame.getpixel((14, 23)) == PINK assert closed_frame.getpixel((38, 23)) == PINK assert closed_frame.getpixel((16, 18)) == BACKGROUND assert open_frame.crop((0, 32, 64, 64)).tobytes() == closed_frame.crop((0, 32, 64, 64)).tobytes() def test_startup_indicator_uses_fixed_brightness_and_saved_orientation(tmp_path): store = ConfigStore(project_root=tmp_path) store.update({"orientation": 90, "brightness": 27, "last_mode": "composition"}) service = DisplayService(store, driver_kind="mock", startup_boot_id="boot-a") try: assert service.start_startup_indicator() is True status = service.get_status() saved = Image.open(tmp_path / "data" / "runtime" / "last_frame.png") assert status["state"] == { "mode": "startup_indicator", "orientation": 90, "brightness": 27, "effective_brightness": 50, "startup_indicator_active": True, "wifi_indicator_active": False, "wifi_indicator_connected": None, "animation_active": False, "animation_id": None, "animation_revision": None, "animation_frame_id": None, "animation_frame_index": None, "animation_playback": { "active": False, "session_id": None, "animation_id": None, "animation_revision": None, "frame_id": None, "frame_index": None, "frame_count": None, "position_ms": None, "total_duration_ms": None, "paused": None, "speed": None, "supported_speeds": [0.5, 1.0, 1.5, 2.0], }, "current_content": {"category": "system", "id": None, "name": "开机提示"}, "display_test": {"active": False, "color": None, "brightness": None}, "ota_update": {"active": False, "percent": None, "stage": None}, "revision": 0, } assert status["screen"]["driver_options"]["brightness"] == 50 assert saved.getpixel((45, 16)) == PINK assert store.config["last_mode"] == "composition" assert store.config["brightness"] == 27 finally: service.close() def test_startup_indicator_blinks_until_dismissed(tmp_path, monkeypatch): import app.display.service as service_module monkeypatch.setattr(service_module, "OPEN_EYE_SECONDS", 0.01) monkeypatch.setattr(service_module, "CLOSED_EYE_SECONDS", 0.01) store = ConfigStore(project_root=tmp_path) service = DisplayService(store, driver_kind="mock", startup_boot_id="boot-a") submitted = [] original_set_image = service.driver.set_image def record_image(image): submitted.append(image.copy()) original_set_image(image) monkeypatch.setattr(service.driver, "set_image", record_image) try: service.start_startup_indicator() deadline = time.monotonic() + 0.5 while len(submitted) < 3 and time.monotonic() < deadline: time.sleep(0.005) assert len(submitted) >= 3 assert submitted[0].getpixel((16, 18)) == PINK # Relaxing from the saved user brightness submits the smile once at # the old dim setting and once again after raising to 50%. assert submitted[1].getpixel((16, 18)) == PINK assert submitted[2].getpixel((16, 18)) == BACKGROUND finally: service.close() def test_successful_display_command_dismisses_for_current_boot_only(tmp_path): store = ConfigStore(project_root=tmp_path) store.update({"brightness": 31}) first = DisplayService(store, driver_kind="mock", startup_boot_id="boot-a") same_boot = None next_boot = None try: first.start_startup_indicator() first.fill((12, 34, 56)) status = first.get_status() marker = json.loads( (tmp_path / "data" / "runtime" / "startup_indicator.json").read_text( encoding="utf-8" ) ) assert status["state"]["startup_indicator_active"] is False assert status["state"]["effective_brightness"] == 31 assert first.driver.brightness == 31 assert marker == {"dismissed_boot_id": "boot-a"} same_boot = DisplayService(store, driver_kind="mock", startup_boot_id="boot-a") assert same_boot.start_startup_indicator() is False next_boot = DisplayService(store, driver_kind="mock", startup_boot_id="boot-b") assert next_boot.start_startup_indicator() is True finally: first.close() if same_boot is not None: same_boot.close() if next_boot is not None: next_boot.close() @pytest.mark.parametrize("mutation", ["orientation", "brightness"]) def test_same_value_screen_setting_still_dismisses_startup_indicator(tmp_path, mutation): store = ConfigStore(project_root=tmp_path) service = DisplayService(store, driver_kind="mock", startup_boot_id=f"boot-{mutation}") try: service.start_startup_indicator() if mutation == "orientation": service.set_orientation(store.config["orientation"]) else: service.set_brightness(store.config["brightness"]) status = service.get_status() assert status["state"]["startup_indicator_active"] is False assert status["state"]["effective_brightness"] == store.config["brightness"] assert status["state"]["revision"] == 1 finally: service.close() def test_invalid_or_failed_display_command_keeps_startup_indicator(tmp_path, monkeypatch): store = ConfigStore(project_root=tmp_path) service = DisplayService(store, driver_kind="mock", startup_boot_id="boot-a") try: service.start_startup_indicator() with pytest.raises(ValueError, match="frame size"): service.show_rgb_bytes(b"bad") assert service.get_status()["state"]["startup_indicator_active"] is True monkeypatch.setattr(store, "set_last_mode", lambda _mode: (_ for _ in ()).throw(OSError("save failed"))) with pytest.raises(OSError, match="save failed"): service.fill((12, 34, 56)) status = service.get_status() assert status["state"]["startup_indicator_active"] is True assert status["state"]["effective_brightness"] == 50 assert service.driver.brightness == 50 assert not (tmp_path / "data" / "runtime" / "startup_indicator.json").exists() finally: service.close() def test_corrupt_startup_session_state_fails_open(tmp_path): store = ConfigStore(project_root=tmp_path) marker = tmp_path / "data" / "runtime" / "startup_indicator.json" marker.write_text("not-json", encoding="utf-8") service = DisplayService(store, driver_kind="mock", startup_boot_id="boot-a") try: assert service.start_startup_indicator() is True finally: service.close() def test_runtime_brightness_updates_driver_options(tmp_path): store = ConfigStore(project_root=tmp_path) service = DisplayService(store, driver_kind="mock") service.set_brightness(35) status = service.get_status() assert status["state"]["brightness"] == 35 assert status["screen"]["driver_options"]["brightness"] == 35 def test_refresh_rate_limit_updates_in_place_and_keeps_current_frame(tmp_path): store = ConfigStore(project_root=tmp_path) service = DisplayService(store, driver_kind="mock") image = Image.new("RGB", (64, 64), (12, 34, 56)) service.show_image(image, mode="composition") previous_driver = service.driver service.set_refresh_rate_limit(60) assert service.driver is previous_driver assert store.config["matrix_refresh_rate_limit_hz"] == 60 assert service.get_status()["screen"]["driver_options"][ "limit_refresh_rate_hz" ] == 60 restored = Image.open(tmp_path / "data" / "runtime" / "last_frame.png") assert restored.getpixel((10, 10)) == (12, 34, 56) def test_refresh_rate_limit_keeps_animation_position_and_playback(tmp_path): store = ConfigStore(project_root=tmp_path) service = DisplayService(store, driver_kind="mock") service.start_animation("animation-a", "revision-a", [ { "id": "a", "image": Image.new("RGB", (64, 64), "red"), "duration_ms": 60000, }, { "id": "b", "image": Image.new("RGB", (64, 64), "green"), "duration_ms": 60000, }, ]) try: before = service.get_status()["state"] service.set_refresh_rate_limit(80) after = service.get_status()["state"] assert after["animation_active"] is True assert after["animation_id"] == "animation-a" assert after["animation_frame_index"] == before["animation_frame_index"] assert after["revision"] == before["revision"] + 1 finally: service.close() def test_animation_playback_pause_seek_speed_loop_and_session_reset(tmp_path): store = ConfigStore(project_root=tmp_path) service = DisplayService(store, driver_kind="mock") frames = [ {"id": "red", "image": Image.new("RGB", (64, 64), "red"), "duration_ms": 500}, {"id": "green", "image": Image.new("RGB", (64, 64), "green"), "duration_ms": 500}, ] try: service.start_animation("animation-a", "revision-a", frames) initial = service.get_status()["state"]["animation_playback"] assert initial["active"] is True assert initial["position_ms"] < 100 assert initial["paused"] is False assert initial["speed"] == 1.0 assert initial["supported_speeds"] == [0.5, 1.0, 1.5, 2.0] first_session = initial["session_id"] paused = service.control_animation_playback( first_session, position_ms=600, paused=True, ) assert paused["position_ms"] == 600 assert paused["frame_id"] == "green" assert service.get_current_frame().getpixel((0, 0)) == (0, 128, 0) time.sleep(0.08) assert service.get_status()["state"]["animation_playback"]["position_ms"] == 600 for speed in (0.5, 1.0, 1.5, 2.0): changed = service.control_animation_playback(first_session, speed=speed) assert changed["speed"] == speed assert changed["paused"] is True service.control_animation_playback(first_session, paused=False, speed=2.0) time.sleep(0.12) advanced = service.get_status()["state"]["animation_playback"]["position_ms"] assert 780 <= advanced <= 980 service.control_animation_playback(first_session, position_ms=999, paused=False, speed=1.0) time.sleep(0.03) wrapped = service.get_status()["state"]["animation_playback"]["position_ms"] assert wrapped < 150 service.start_animation("animation-a", "revision-a", frames) restarted = service.get_status()["state"]["animation_playback"] assert restarted["session_id"] != first_session assert restarted["position_ms"] < 100 assert restarted["paused"] is False assert restarted["speed"] == 1.0 with pytest.raises(AnimationPlaybackConflictError): service.control_animation_playback(first_session, paused=True) service.show_image(Image.new("RGB", (64, 64), "blue"), mode="image") inactive = service.get_status()["state"]["animation_playback"] assert inactive["active"] is False assert inactive["session_id"] is None assert inactive["supported_speeds"] == [0.5, 1.0, 1.5, 2.0] finally: service.close() def test_refresh_rate_config_write_failure_rolls_back_driver_and_frame( tmp_path, monkeypatch, ): store = ConfigStore(project_root=tmp_path) service = DisplayService(store, driver_kind="mock") image = Image.new("RGB", (64, 64), (9, 8, 7)) service.show_image(image, mode="composition") original_update = store.update def fail_refresh(values): if "matrix_refresh_rate_limit_hz" in values: raise OSError("simulated config write failure") return original_update(values) monkeypatch.setattr(store, "update", fail_refresh) with pytest.raises(RuntimeError, match="saved setting was not changed"): service.set_refresh_rate_limit(60) assert store.config["matrix_refresh_rate_limit_hz"] == 100 assert service.get_status()["screen"]["driver_options"][ "limit_refresh_rate_hz" ] == 100 restored = Image.open(tmp_path / "data" / "runtime" / "last_frame.png") assert restored.getpixel((10, 10)) == (9, 8, 7) def test_refresh_rate_driver_update_failure_restores_original_limit( tmp_path, monkeypatch, ): store = ConfigStore(project_root=tmp_path) service = DisplayService(store, driver_kind="mock") original_set = service.driver.set_refresh_rate_limit def fail_requested(value): if value == 60: raise RuntimeError("simulated driver update failure") return original_set(value) monkeypatch.setattr(service.driver, "set_refresh_rate_limit", fail_requested) with pytest.raises(RuntimeError, match="saved setting was not changed"): service.set_refresh_rate_limit(60) assert store.config["matrix_refresh_rate_limit_hz"] == 100 status = service.get_status()["screen"] assert status["driver_available"] is True assert status["driver_options"]["limit_refresh_rate_hz"] == 100 def test_refresh_rate_double_failure_reports_rollback_error_without_mock_fallback( tmp_path, monkeypatch, ): store = ConfigStore(project_root=tmp_path) service = DisplayService(store, driver_kind="mock") original_driver = service.driver def fail_all(_value): raise RuntimeError("simulated native update and rollback failure") monkeypatch.setattr(service.driver, "set_refresh_rate_limit", fail_all) with pytest.raises(RuntimeError, match="rollback also failed"): service.set_refresh_rate_limit(60) assert store.config["matrix_refresh_rate_limit_hz"] == 100 status = service.get_status()["screen"] assert service.driver is original_driver assert status["driver_available"] is True assert status["driver_error"] is None assert status["driver_options"]["limit_refresh_rate_hz"] == 100 def test_state_revision_changes_only_after_successful_mutations(tmp_path): store = ConfigStore(project_root=tmp_path) service = DisplayService(store, driver_kind="mock") assert service.get_status()["state"]["revision"] == 0 service.set_orientation(90) assert service.get_status()["state"]["revision"] == 1 service.set_orientation(90) assert service.get_status()["state"]["revision"] == 1 service.set_brightness(35) assert service.get_status()["state"]["revision"] == 2 service.fill((1, 2, 3)) assert service.get_status()["state"]["revision"] == 3 def test_runtime_brightness_preserves_current_frame_and_mode(tmp_path): store = ConfigStore(project_root=tmp_path) service = DisplayService(store, driver_kind="mock") image = Image.new("RGB", (64, 64), (0, 0, 0)) image.putpixel((3, 5), (255, 0, 0)) service.show_image(image, mode="canvas") frame_path = tmp_path / "data" / "runtime" / "last_frame.png" before = frame_path.read_bytes() service.set_brightness(35) assert frame_path.read_bytes() == before assert store.config["last_mode"] == "canvas" def test_runtime_brightness_resubmits_current_transformed_frame(tmp_path, monkeypatch): store = ConfigStore(project_root=tmp_path) service = DisplayService(store, driver_kind="mock") image = Image.new("RGB", (64, 64), (0, 0, 0)) image.putpixel((0, 0), (255, 0, 0)) service.show_image(image, mode="canvas") service.set_orientation(90) submitted = [] original_set_image = service.driver.set_image def record_image(frame): submitted.append(frame.copy()) original_set_image(frame) monkeypatch.setattr(service.driver, "set_image", record_image) service.set_brightness(35) assert len(submitted) == 1 assert submitted[0].getpixel((63, 0)) == (255, 0, 0) assert store.config["last_mode"] == "canvas" assert service.get_status()["state"]["revision"] == 3 def test_runtime_brightness_failure_restores_previous_brightness_and_frame(tmp_path, monkeypatch): store = ConfigStore(project_root=tmp_path) service = DisplayService(store, driver_kind="mock") service.fill((12, 34, 56)) brightness_updates = [] frame_brightnesses = [] original_set_brightness = service.driver.set_brightness original_set_image = service.driver.set_image def record_brightness(value): brightness_updates.append(value) original_set_brightness(value) def record_image(frame): frame_brightnesses.append(service.driver.brightness) original_set_image(frame) def fail_update(_update): raise OSError("config write failed") monkeypatch.setattr(service.driver, "set_brightness", record_brightness) monkeypatch.setattr(service.driver, "set_image", record_image) monkeypatch.setattr(store, "update", fail_update) with pytest.raises(OSError, match="config write failed"): service.set_brightness(35) assert brightness_updates == [35, 40] assert frame_brightnesses == [35, 35, 40] assert service.driver.brightness == 40 assert store.config["brightness"] == 40 assert store.config["last_mode"] == "fill" assert service.get_status()["state"]["revision"] == 1 def test_low_voltage_indicator_is_fixed_red_pixel_art_on_black(): frame = render_low_voltage_indicator() assert frame.mode == "RGB" assert frame.size == (64, 64) assert {color for _count, color in frame.getcolors(maxcolors=4096)} == { LOW_VOLTAGE_BACKGROUND, LOW_VOLTAGE_RED, } assert frame.getpixel((0, 0)) == LOW_VOLTAGE_BACKGROUND assert frame.getpixel((9, 17)) == LOW_VOLTAGE_RED assert frame.getpixel((53, 31)) == LOW_VOLTAGE_RED assert frame.getpixel((30, 31)) == LOW_VOLTAGE_BACKGROUND assert frame.getpixel((18, 36)) == LOW_VOLTAGE_RED def test_power_limit_caps_effective_output_without_mutating_user_state(tmp_path): store = ConfigStore(project_root=tmp_path) store.update({"brightness": 90, "last_mode": "composition"}) service = DisplayService(store, driver_kind="mock") frame = Image.new("RGB", (64, 64), (12, 34, 56)) service.show_image(frame, mode="canvas") revision = service.get_status()["state"]["revision"] service.apply_power_protection(protection_directive(5, "limiting", 75)) state = service.get_status()["state"] assert state["mode"] == "canvas" assert state["brightness"] == 90 assert state["effective_brightness"] == 75 assert state["revision"] == revision assert store.config["brightness"] == 90 assert service.get_current_frame().tobytes() == frame.tobytes() # An older, stricter sample is ignored rather than arriving late and # replacing the already published monitor decision. service.apply_power_protection(protection_directive(4, "critical", 35)) assert service.get_status()["state"]["effective_brightness"] == 75 assert service.get_current_frame().tobytes() == frame.tobytes() service.set_brightness(40) service.apply_power_protection(protection_directive(6, "limiting", 50)) state = service.get_status()["state"] assert state["brightness"] == 40 assert state["effective_brightness"] == 40 assert store.config["brightness"] == 40 service.apply_power_protection(protection_directive(7, "inactive")) assert service.get_status()["state"]["effective_brightness"] == 40 def test_critical_overlay_preserves_and_restores_latest_user_state(tmp_path): store = ConfigStore(project_root=tmp_path) store.update({"brightness": 70, "last_mode": "composition"}) service = DisplayService(store, driver_kind="mock", startup_boot_id="boot-critical") user_frame = Image.new("RGB", (64, 64), (0, 0, 0)) user_frame.putpixel((0, 0), (12, 34, 56)) try: assert service.start_startup_indicator() is True service.apply_power_protection(protection_directive(1, "critical", 35)) state = service.get_status()["state"] assert state["mode"] == "low_voltage_indicator" assert state["brightness"] == 70 assert state["effective_brightness"] == 35 assert state["startup_indicator_active"] is True assert state["revision"] == 0 assert store.config["last_mode"] == "composition" assert service.get_current_frame().tobytes() == render_low_voltage_indicator().tobytes() # User mutations remain durable underneath the overlay and a # successful display command still dismisses the boot smile. service.show_image(user_frame, mode="canvas") service.set_brightness(80) service.set_orientation(90) state = service.get_status()["state"] saved = Image.open(tmp_path / "data" / "runtime" / "last_frame.png").convert("RGB") expected_indicator = apply_orientation(render_low_voltage_indicator(), 90) assert state["mode"] == "low_voltage_indicator" assert state["brightness"] == 80 assert state["effective_brightness"] == 35 assert state["orientation"] == 90 assert state["startup_indicator_active"] is False assert state["revision"] == 3 assert store.config["last_mode"] == "canvas" assert saved.tobytes() == expected_indicator.tobytes() service.apply_power_protection(protection_directive(2, "inactive")) state = service.get_status()["state"] saved = Image.open(tmp_path / "data" / "runtime" / "last_frame.png").convert("RGB") assert state["mode"] == "canvas" assert state["brightness"] == 80 assert state["effective_brightness"] == 80 assert state["revision"] == 3 assert service.get_current_frame().tobytes() == user_frame.tobytes() assert saved.tobytes() == apply_orientation(user_frame, 90).tobytes() finally: service.close() def test_startup_indicator_resumes_below_limit_after_critical_recovers(tmp_path): store = ConfigStore(project_root=tmp_path) service = DisplayService(store, driver_kind="mock", startup_boot_id="boot-resume") try: service.start_startup_indicator() service.apply_power_protection(protection_directive(1, "critical", 35)) service.apply_power_protection(protection_directive(2, "limiting", 30)) state = service.get_status()["state"] assert state["mode"] == "startup_indicator" assert state["effective_brightness"] == 30 assert state["startup_indicator_active"] is True assert service.get_current_frame().tobytes() == render_startup_smile().tobytes() finally: service.close() @pytest.mark.parametrize("orientation", [0, 90, 180, 270]) def test_critical_indicator_inherits_all_orientations(tmp_path, orientation): store = ConfigStore(project_root=tmp_path) store.update({"orientation": orientation}) service = DisplayService(store, driver_kind="mock") service.apply_power_protection(protection_directive(1, "critical", 35)) saved = Image.open(tmp_path / "data" / "runtime" / "last_frame.png").convert("RGB") expected = apply_row_bit_order( apply_orientation(render_low_voltage_indicator(), orientation), get_row_bit_order(), ) assert saved.tobytes() == expected.tobytes() def test_protection_transitions_use_safe_driver_call_order(tmp_path, monkeypatch): store = ConfigStore(project_root=tmp_path) store.update({"brightness": 80}) service = DisplayService(store, driver_kind="mock") events = [] original_set_brightness = service.driver.set_brightness original_set_image = service.driver.set_image def record_brightness(value): events.append(("brightness", value)) original_set_brightness(value) def record_image(frame): events.append(("image", service.driver.brightness)) original_set_image(frame) monkeypatch.setattr(service.driver, "set_brightness", record_brightness) monkeypatch.setattr(service.driver, "set_image", record_image) service.apply_power_protection(protection_directive(1, "limiting", 60)) assert events == [("brightness", 60), ("image", 60)] events.clear() service.apply_power_protection(protection_directive(2, "inactive")) assert events == [("image", 60), ("brightness", 80), ("image", 80)] def test_failed_tightening_keeps_lower_brightness_and_equal_sequence_retries( tmp_path, monkeypatch, ): store = ConfigStore(project_root=tmp_path) service = DisplayService(store, driver_kind="mock") service.fill((12, 34, 56)) user_frame = service.get_current_frame() original_set_image = service.driver.set_image attempts = 0 def fail_first_image(frame): nonlocal attempts attempts += 1 if attempts == 1: raise OSError("frame submit failed") original_set_image(frame) monkeypatch.setattr(service.driver, "set_image", fail_first_image) directive = protection_directive(9, "critical", 35) with pytest.raises(OSError, match="frame submit failed"): service.apply_power_protection(directive) state = service.get_status()["state"] assert state["mode"] == "low_voltage_indicator" assert state["effective_brightness"] == 35 assert state["revision"] == 1 assert service.driver.brightness == 35 assert service.get_current_frame().tobytes() == user_frame.tobytes() service.apply_power_protection(directive) assert service.get_current_frame().tobytes() == render_low_voltage_indicator().tobytes() assert attempts == 2 def test_failed_relaxation_keeps_old_brightness_and_equal_sequence_retries( tmp_path, monkeypatch, ): store = ConfigStore(project_root=tmp_path) service = DisplayService(store, driver_kind="mock") user_frame = Image.new("RGB", (64, 64), (12, 34, 56)) service.show_image(user_frame, mode="canvas") service.apply_power_protection(protection_directive(1, "critical", 35)) original_set_brightness = service.driver.set_brightness failed = False def fail_first_restore(value): nonlocal failed if value == 40 and not failed: failed = True raise RuntimeError("brightness restore failed") original_set_brightness(value) monkeypatch.setattr(service.driver, "set_brightness", fail_first_restore) directive = protection_directive(2, "inactive") with pytest.raises(RuntimeError, match="brightness restore failed"): service.apply_power_protection(directive) assert service.get_status()["state"]["effective_brightness"] == 35 assert service.driver.brightness == 35 assert service.get_current_frame().tobytes() == user_frame.tobytes() service.apply_power_protection(directive) assert service.get_status()["state"]["effective_brightness"] == 40 assert service.driver.brightness == 40 assert service.get_current_frame().tobytes() == user_frame.tobytes() def test_matrix_driver_keeps_brightness_when_native_update_fails(): driver = MatrixDisplayDriver.__new__(MatrixDisplayDriver) driver._context = 1 driver._library = SimpleNamespace(hub75_set_brightness=lambda _context, _value: -1) driver._stats_error = lambda: "native brightness not supported" driver.brightness = 40 driver.driver_options = get_default_driver_options(40) with pytest.raises(RuntimeError, match="not supported"): driver.set_brightness(25) assert driver.brightness == 40 assert driver.driver_options.brightness == 40 def test_display_service_rejects_wrong_frame_size(tmp_path): store = ConfigStore(project_root=tmp_path) service = DisplayService(store, driver_kind="mock") with pytest.raises(ValueError, match="frame size"): service.show_rgb_bytes(b"bad") def test_orientation_90_rotates_clockwise(tmp_path): store = ConfigStore(project_root=tmp_path) service = DisplayService(store, driver_kind="mock") service.set_orientation(90) image = Image.new("RGB", (64, 64), (0, 0, 0)) image.putpixel((0, 0), (255, 0, 0)) service.show_image(image) saved = Image.open(tmp_path / "data" / "runtime" / "last_frame.png") assert saved.getpixel((63, 0)) == (255, 0, 0) def test_orientation_change_reapplies_current_frame_without_changing_mode(tmp_path): store = ConfigStore(project_root=tmp_path) service = DisplayService(store, driver_kind="mock") image = Image.new("RGB", (64, 64), (0, 0, 0)) image.putpixel((0, 0), (255, 0, 0)) image.putpixel((63, 0), (0, 255, 0)) service.show_image(image, mode="text") service.set_orientation(90) saved = Image.open(tmp_path / "data" / "runtime" / "last_frame.png") assert saved.getpixel((63, 0)) == (255, 0, 0) assert saved.getpixel((63, 63)) == (0, 255, 0) assert store.config["last_mode"] == "text" assert store.config["orientation"] == 90 def test_row_bit_order_remaps_low_five_row_bits(monkeypatch): monkeypatch.setenv("MATRIX_ROW_BIT_ORDER", "4,3,2,1,0") image = Image.new("RGB", (64, 64), (0, 0, 0)) image.putpixel((0, 16), (255, 0, 0)) remapped = apply_row_bit_order(image, get_row_bit_order()) assert remapped.getpixel((0, 1)) == (255, 0, 0) assert remapped.getpixel((0, 16)) == (0, 0, 0) def test_text_renderer_path(tmp_path): store = ConfigStore(project_root=tmp_path) service = DisplayService(store, driver_kind="mock") service.show_text(TextOptions(text="OK", size=12, x=4, y=24)) assert store.config["last_mode"] == "text" def test_text_layer_is_transparent_and_legacy_text_keeps_background(): options = TextOptions( text="OK", size=24, x=4, y=4, color=(10, 20, 30), background=(40, 50, 60), ) layer = render_text_layer(options) legacy = render_text(options) assert layer.mode == "RGBA" assert layer.size == (64, 64) assert layer.getpixel((63, 63)) == (0, 0, 0, 0) assert layer.getchannel("A").getbbox() is not None assert layer.getchannel("R").getextrema()[1] == 10 assert layer.getchannel("G").getextrema()[1] == 20 assert layer.getchannel("B").getextrema()[1] == 30 assert layer.getchannel("A").getextrema()[1] == 255 assert legacy.mode == "RGB" assert legacy.getpixel((63, 63)) == (40, 50, 60) expected = Image.alpha_composite( Image.new("RGBA", (64, 64), (40, 50, 60, 255)), layer, ).convert("RGB") assert legacy.tobytes() == expected.tobytes() def test_multilingual_font_resolution_covers_representative_languages(monkeypatch): samples = ( "你好", "日本", "한글", "Привет", "Ελλάδα", "مرحبا", "שלום", "नमस्ते", "สวัสดี", ) face = FontFace("multilingual-test.ttf") coverage = frozenset(ord(character) for sample in samples for character in sample) monkeypatch.setattr(text_renderer, "fallback_font_faces", lambda: (face,)) monkeypatch.setattr(text_renderer, "_preferred_font_face", lambda _language: None) monkeypatch.setattr(text_renderer, "_font_coverage", lambda _face: coverage) for sample in samples: assert text_renderer.resolve_font_face("default", sample) == face def test_custom_font_missing_glyphs_falls_back_for_the_complete_text(monkeypatch): custom = FontFace("latin-only.ttf") fallback = FontFace("cjk-with-latin.ttf") coverage = { custom: frozenset(map(ord, "temperature 25C")), fallback: frozenset(map(ord, "温度 25°C")), } monkeypatch.setattr(text_renderer, "_custom_font_faces", lambda _font: (custom,)) monkeypatch.setattr(text_renderer, "fallback_font_faces", lambda: (fallback,)) monkeypatch.setattr(text_renderer, "_preferred_font_face", lambda _language: None) monkeypatch.setattr(text_renderer, "_font_coverage", lambda face: coverage[face]) assert text_renderer.resolve_font_face("latin-only.ttf", "温度 25°C") == fallback def test_unsupported_glyph_reports_unicode_codepoint(monkeypatch): face = FontFace("language-font.ttf") monkeypatch.setattr(text_renderer, "fallback_font_faces", lambda: (face,)) monkeypatch.setattr(text_renderer, "_preferred_font_face", lambda _language: None) monkeypatch.setattr(text_renderer, "_font_coverage", lambda _face: frozenset(map(ord, "ABC"))) assert text_renderer.resolve_font_face("default", "😀") is None with pytest.raises(ValueError, match=r"U\+1F600"): text_renderer.load_font("default", 18, "😀") @pytest.mark.parametrize(("sample", "language"), [ ("こんにちは", "ja"), ("한글", "ko"), ("你好", "zh-cn"), ("مرحبا", "ar"), ("שלום", "he"), ("नमस्ते", "hi"), ("สวัสดี", "th"), ("Привет", "ru"), ("Ελλάδα", "el"), ]) def test_text_script_selects_the_matching_fontconfig_language(sample, language): assert text_renderer._preferred_font_language(sample) == language def test_preferred_font_short_circuits_fallback_discovery(monkeypatch): preferred = FontFace("preferred.ttf") monkeypatch.setattr(text_renderer, "_preferred_font_face", lambda _language: preferred) monkeypatch.setattr(text_renderer, "_font_coverage", lambda _face: frozenset(map(ord, "你好"))) def unexpected_fallback(): raise AssertionError("fallback discovery should not run") monkeypatch.setattr(text_renderer, "fallback_font_faces", unexpected_fallback) assert text_renderer.resolve_font_face("default", "你好") == preferred def test_noto_cjk_uses_region_specific_ttc_faces(monkeypatch, tmp_path): cjk = tmp_path / "NotoSansCJK-Regular.ttc" cjk.touch() monkeypatch.setattr(text_renderer, "PREFERRED_FONT_CANDIDATES", {}) monkeypatch.setattr(text_renderer, "_fontconfig_face", lambda _language: FontFace(str(cjk), 2)) assert text_renderer._preferred_font_face("ja").index == 0 assert text_renderer._preferred_font_face("ko").index == 1 assert text_renderer._preferred_font_face("zh-cn").index == 2 def test_truetype_loader_prefers_raqm_when_available(monkeypatch): captured = {} text_renderer._load_truetype.cache_clear() monkeypatch.setattr(text_renderer.features, "check_feature", lambda name: name == "raqm") def fake_truetype(path, **kwargs): captured.update(path=path, **kwargs) return object() monkeypatch.setattr(text_renderer.ImageFont, "truetype", fake_truetype) text_renderer._load_truetype(FontFace("complex-script.ttf", 2), 14) assert captured["index"] == 2 assert captured["layout_engine"] == text_renderer.ImageFont.Layout.RAQM text_renderer._load_truetype.cache_clear() def test_diagnostic_frame_has_corner_markers(): image = render_diagnostic("corners_lines") assert image.getpixel((0, 0)) == (255, 0, 0) assert image.getpixel((63, 0)) == (0, 255, 0) assert image.getpixel((0, 63)) == (0, 0, 255) assert image.getpixel((63, 63)) == (255, 255, 255) def test_address_bit_diagnostic_marks_expected_rows(): image = render_diagnostic("address_bit_3") assert image.getpixel((10, 7)) == (0, 0, 0) assert image.getpixel((10, 8)) == (255, 255, 0) assert image.getpixel((10, 15)) == (255, 255, 0) assert image.getpixel((10, 16)) == (0, 0, 0) def test_display_service_writes_diagnostic_mock_frame(tmp_path): store = ConfigStore(project_root=tmp_path) service = DisplayService(store, driver_kind="mock") service.show_diagnostic("row_bands") saved = Image.open(tmp_path / "data" / "runtime" / "last_frame.png") assert store.config["last_mode"] == "diagnostic:row_bands" assert saved.getpixel((4, 4)) == (255, 0, 0) assert saved.getpixel((4, 60)) == (255, 255, 255) def test_driver_options_ignore_legacy_tuning_environment(monkeypatch): monkeypatch.setenv("MATRIX_ROW_ADDRESS_TYPE", "1") monkeypatch.setenv("MATRIX_MULTIPLEXING", "2") monkeypatch.setenv("MATRIX_SCAN_MODE", "1") monkeypatch.setenv("MATRIX_RGB_SEQUENCE", "RBG") monkeypatch.setenv("MATRIX_PANEL_TYPE", "FM6126A") monkeypatch.setenv("MATRIX_GPIO_SLOWDOWN", "5") monkeypatch.setenv("MATRIX_PWM_BITS", "6") options = get_matrix_driver_options(35) assert options.brightness == 35 assert options.scan_rows == 32 assert options.row_address_bits == 5 assert options.hardware_mapping == "walnutpi-pi-bank-pwm-oe-v2" assert options.pwm_bits == 7 def test_driver_defaults_fix_walnutpi_native_contract(): options = get_default_driver_options(40) assert options.rows == 64 assert options.cols == 64 assert options.scan_rows == 32 assert options.row_address_bits == 5 assert options.pwm_bits == 7 assert options.pio_base == "0x0300B000" assert options.pi_bank_offset == "0x120" assert options.data_offset == "0x10" assert options.limit_refresh_rate_hz == 100 @pytest.mark.parametrize("value", [15, 20, 30, 45, 60, 80, 100]) def test_driver_options_accept_refresh_rate_presets(value): assert get_matrix_driver_options(35, value).limit_refresh_rate_hz == value @pytest.mark.parametrize("value", [0, 14, 16, 101]) def test_driver_options_reject_unknown_refresh_rate_presets(value): with pytest.raises(ValueError, match="limit_refresh_rate_hz"): get_matrix_driver_options(35, value) def test_real_driver_uses_native_library_and_reports_runtime_stats(monkeypatch): class FakeNative: def __init__(self): self.created = None self.destroyed = False def hub75_create(self, config, _error, _size): self.created = config._obj return 123 def hub75_start(self, _context): return 0 def hub75_submit_rgb(self, _context, _buffer, length): return 0 if length == 64 * 64 * 3 else -1 def hub75_set_brightness(self, _context, _value): return 0 def hub75_set_refresh_rate(self, _context, _value): return 0 def hub75_clear(self, _context): return 0 def hub75_get_stats(self, _context, stats): value = stats._obj value.actual_refresh_rate_hz = 44.8 value.panel_scan_rate_hz = 89.6 value.completed_frames = 100 value.completed_scans = 200 value.scans_per_frame = 2 value.buffer_swaps = 2 value.cpu_affinity = 3 value.cpu_affinity_active = 1 value.realtime_priority = 50 value.realtime_priority_active = 1 value.memory_locked = 1 value.running = 1 value.brightness = 40 value.refresh_rate_hz = 45 value.governor = b"performance" return 0 def hub75_destroy(self, _context): self.destroyed = True native = FakeNative() monkeypatch.setattr(matrix_driver_module, "_load_native_library", lambda: native) driver = MatrixDisplayDriver(40, limit_refresh_rate_hz=45) assert native.created.refresh_rate_hz == 45 status = driver.get_status() assert status["driver"] == "walnutpi-h618-hub75" assert status["driver_options"]["limit_refresh_rate_hz"] == 45 assert status["driver_status"]["actual_refresh_rate_hz"] == 44.8 assert status["driver_status"]["panel_scan_rate_hz"] == 89.6 assert status["driver_status"]["completed_scans"] == 200 assert status["driver_status"]["scans_per_frame"] == 2 assert status["driver_status"]["cpu_affinity_active"] is True assert status["driver_status"]["governor"] == "performance" driver.close() assert native.destroyed is True assert driver._context is None def test_real_driver_reports_missing_native_library(monkeypatch): monkeypatch.setattr(matrix_driver_module, "NATIVE_LIBRARY", Path("missing-native-driver.so")) with pytest.raises(RuntimeError, match="native HUB75 library is missing"): MatrixDisplayDriver(40)