Files
matrix-screen-controller/核桃派软件源代码/tests/test_display_service.py
T

1303 lines
50 KiB
Python

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)