import base64 import threading from io import BytesIO from pathlib import Path from fastapi.testclient import TestClient from PIL import Image from app.config.store import ConfigStore from app.demo_library import DEMO_ANIMATION_ID, DEMO_ANIMATION_NAME from app.main import create_app, static_content_version from app.network.manager import MockNetworkManager from app.power.voltage import VoltageMonitor def solid_scene(color=(12, 34, 56)): return { "version": 1, "width": 64, "height": 64, "pixelRgb": base64.b64encode(bytes(color) * (64 * 64)).decode("ascii"), "elements": [], } def test_homepage_exposes_escaped_boot_session_without_changing_rest_contract(tmp_path): boot_id = 'boot-"&' with TestClient(create_app(project_root=tmp_path, driver_kind="mock", startup_boot_id=boot_id)) as client: response = client.get("/") assert response.status_code == 200 assert '' in response.text assert response.headers["cache-control"] == "no-store" assert 'name="device-boot-id"' not in client.get("/api/status").text def test_status_config_and_display_api(tmp_path): client = TestClient(create_app(project_root=tmp_path, driver_kind="mock")) status = client.get("/api/status") assert status.status_code == 200 assert status.json()["screen"]["driver"] == "mock" assert status.json()["screen"]["driver_options"]["rows"] == 64 assert status.json()["screen"]["driver_options"]["scan_rows"] == 32 assert status.json()["screen"]["driver_options"]["row_address_bits"] == 5 assert status.json()["screen"]["driver_options"]["pwm_bits"] == 7 assert status.json()["screen"]["hardware_mapping"] == "walnutpi-pi-bank-pwm-oe-v2" assert status.json()["screen"]["driver_options"]["limit_refresh_rate_hz"] == 100 assert status.json()["screen"]["driver_status"]["actual_refresh_rate_hz"] is None assert status.json()["screen"]["driver_status"]["panel_scan_rate_hz"] is None assert status.json()["screen"]["driver_status"]["completed_scans"] is None assert status.json()["screen"]["driver_status"]["scans_per_frame"] is None assert status.json()["power"]["screen_input_voltage"]["status"] == "starting" assert status.json()["state"]["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], } assert status.json()["power"]["low_voltage_protection"] == { "enabled": False, "mode": "disabled", "brightness_limit_percent": None, "reading_stale": False, "revision": 0, "sampling_interval_seconds": 5.0, "enforcement_error_code": None, } assert status.json()["resources"] == { "status": "starting", "sampled_at": None, "cpu": { "application_percent": None, "total_percent": None, "logical_cpu_count": None, "application_core_equivalent": None, "cores_percent": [], }, "memory": { "application_bytes": None, "application_percent": None, "total_percent": None, }, "error_code": None, } assert status.json()["ui"]["preview_refresh_interval_ms"] == 1000 assert status.json()["system"]["performance_mode"] == { "requested": False, "available": False, "effective": False, "current_governors": {}, "restore_governors": {}, "last_error": "当前内核未提供 CPU 调频策略;请检查候选内核是否回退", } assert status.json()["state"]["display_test"] == { "active": False, "color": None, "brightness": None, } config = client.put( "/api/config", json={ "orientation": 180, "brightness": 30, "preview_refresh_interval_ms": 99, "matrix_refresh_rate_limit_hz": 80, "custom_test_color": "#a1b2c3", "workspace_order": ["templates", "device", "settings", "canvas", "text", "animations"], "animation_preview_max_concurrent": 50, }, ) assert config.status_code == 200 assert config.json()["orientation"] == 180 assert config.json()["preview_refresh_interval_ms"] == 99 assert config.json()["matrix_refresh_rate_limit_hz"] == 80 assert config.json()["custom_test_color"] == "#A1B2C3" assert config.json()["workspace_order"] == [ "templates", "device", "settings", "canvas", "text", "animations", ] assert client.get("/api/config").json()["preview_refresh_interval_ms"] == 99 assert client.get("/api/config").json()["low_voltage_protection_enabled"] is False assert client.get("/api/status").json()["ui"]["preview_refresh_interval_ms"] == 99 assert client.get("/api/status").json()["screen"]["driver_options"][ "limit_refresh_rate_hz" ] == 80 assert "schema_version" not in config.json() assert config.json()["performance_mode_enabled"] is False assert config.json()["animation_preview_max_concurrent"] == 50 homepage = client.get("/") assert 'id="topbar-cpu"' in homepage.text assert 'id="topbar-memory"' in homepage.text assert 'id="topbar-low-voltage-warning"' in homepage.text assert 'id="low-voltage-protection-enabled"' in homepage.text assert 'id="brightness-protection-status"' in homepage.text assert 'id="resource-details-dialog"' in homepage.text assert 'id="matrix-refresh-rate-grid"' in homepage.text assert homepage.text.count("data-matrix-refresh-rate=") == 7 assert 'aria-controls="resource-details-dialog"' in homepage.text assert "资源占用详情" in homepage.text assert ( homepage.text.index('id="topbar-cpu"') < homepage.text.index('id="topbar-memory"') < homepage.text.index('id="topbar-low-voltage-warning"') < homepage.text.index('id="topbar-voltage"') ) for value in (0, 60001): assert client.put( "/api/config", json={"preview_refresh_interval_ms": value}, ).status_code == 400 for value in (True, 1.5, "100"): assert client.put( "/api/config", json={"preview_refresh_interval_ms": value}, ).status_code == 422 for value in (1, 2, 50): response = client.put( "/api/config", json={"animation_preview_max_concurrent": value}, ) assert response.status_code == 200 assert response.json()["animation_preview_max_concurrent"] == value for value in (0, 51): assert client.put( "/api/config", json={"animation_preview_max_concurrent": value}, ).status_code == 400 for value in (None, True, 2.0, "2", [], {}): assert client.put( "/api/config", json={"animation_preview_max_concurrent": value}, ).status_code == 422 for value in (None, 0, 1, "true", [], {}): assert client.put( "/api/config", json={"low_voltage_protection_enabled": value}, ).status_code == 422 assert client.get("/api/config").json()["low_voltage_protection_enabled"] is False for value in (15, 20, 30, 45, 60, 80, 100): response = client.put( "/api/config", json={"matrix_refresh_rate_limit_hz": value}, ) assert response.status_code == 200 assert response.json()["matrix_refresh_rate_limit_hz"] == value assert client.get("/api/status").json()["screen"]["driver_options"][ "limit_refresh_rate_hz" ] == value for value in (0, 14, 16, 101): assert client.put( "/api/config", json={"matrix_refresh_rate_limit_hz": value}, ).status_code == 400 for value in (True, 60.0, "60", [], {}): assert client.put( "/api/config", json={"matrix_refresh_rate_limit_hz": value}, ).status_code == 422 for value in (None, True, 123, "red", "#12345"): response = client.put("/api/config", json={"custom_test_color": value}) assert response.status_code in {400, 422} saved_workspace_order = client.get("/api/config").json()["workspace_order"] for value in (None, [], ["device", "device"], ["bad id"], [1, "device"], "device"): response = client.put("/api/config", json={"workspace_order": value}) assert response.status_code in {400, 422} assert client.get("/api/config").json()["workspace_order"] == saved_workspace_order legacy_config = client.put("/api/config", json={"default_font": "default", "default_text_size": 14}) assert legacy_config.status_code == 200 assert legacy_config.json()["default_text_size"] == 14 fill = client.post("/api/display/fill", json={"color": "#00FF00"}) assert fill.status_code == 200 assert fill.json() == {"ok": True, "mode": "fill"} text = client.post("/api/display/text", json={"text": "OK", "size": 12, "x": 4, "y": 24}) assert text.status_code == 200 assert text.json()["mode"] == "text" diagnostic = client.post("/api/display/diagnostic", json={"mode": "corners_lines"}) assert diagnostic.status_code == 200 assert diagnostic.json() == {"ok": True, "mode": "diagnostic:corners_lines"} def test_display_test_api_is_temporary_persistent_across_reads_and_restorable(tmp_path): app = create_app(project_root=tmp_path, driver_kind="mock") client = TestClient(app) assert client.put("/api/config", json={"brightness": 31}).status_code == 200 assert client.post("/api/display/fill", json={"color": "#010203"}).status_code == 200 started = client.post("/api/display/test/fill", json={"color": "#FF0000"}) assert started.status_code == 200 assert started.json()["display_test"] == { "active": True, "color": "#FF0000", "brightness": 50, } assert started.json()["effective_brightness"] == 50 assert client.get("/api/config").json()["brightness"] == 31 assert client.get("/api/status").json()["state"]["display_test"]["active"] is True changed = client.put("/api/display/test/brightness", json={"brightness": 72}) assert changed.status_code == 200 assert changed.json()["display_test"]["brightness"] == 72 switched = client.post("/api/display/test/fill", json={"color": "#0000FF"}) assert switched.json()["display_test"]["brightness"] == 72 exited = client.delete("/api/display/test") assert exited.status_code == 200 assert exited.json()["display_test"]["active"] is False assert client.delete("/api/display/test").status_code == 200 final = client.get("/api/status").json()["state"] assert final["mode"] == "fill" assert final["brightness"] == 31 assert final["effective_brightness"] == 31 restored = Image.open(BytesIO(client.get("/api/display/current-frame").content)) assert restored.getpixel((0, 0)) == (1, 2, 3) assert client.put("/api/display/test/brightness", json={"brightness": 50}).status_code == 409 assert client.put("/api/display/test/brightness", json={"brightness": True}).status_code == 422 assert client.post("/api/display/test/fill", json={"color": "bad"}).status_code == 400 def test_device_storage_api_and_successful_persistent_write_invalidate_cache(tmp_path): class FakeStorageMonitor: def __init__(self): self.invalidations = 0 def get_status(self): return { "sampled_at": "2026-07-29T00:00:00Z", "device_total_bytes": 1000, "device_free_bytes": 700, "software_bytes": 200, "templates_bytes": 50, } def invalidate(self): self.invalidations += 1 fake = FakeStorageMonitor() app = create_app( project_root=tmp_path, driver_kind="mock", storage_monitor_factory=lambda _program, _data: fake, ) client = TestClient(app) response = client.get("/api/device-storage") assert response.status_code == 200 assert response.json()["software_bytes"] == 200 assert response.headers["cache-control"] == "no-store" assert fake.invalidations == 0 assert client.put("/api/config", json={"custom_test_color": "#112233"}).status_code == 200 assert fake.invalidations == 1 assert client.post("/api/display/test/fill", json={"color": "#112233"}).status_code == 200 assert client.put("/api/display/test/brightness", json={"brightness": 45}).status_code == 200 assert fake.invalidations == 1 def test_current_display_frame_api_is_logical_and_side_effect_free(tmp_path): app = create_app(project_root=tmp_path, driver_kind="mock") client = TestClient(app) frame = bytearray([0, 0, 0] * (64 * 64)) frame[(7 * 64 + 2) * 3:(7 * 64 + 2) * 3 + 3] = bytes((12, 34, 56)) app.state.display_service.show_rgb_bytes(bytes(frame), mode="composition") app.state.display_service.set_orientation(270) before = client.get("/api/status").json()["state"] response = client.get("/api/display/current-frame") image = Image.open(BytesIO(response.content)) assert response.status_code == 200 assert response.headers["content-type"] == "image/png" assert response.headers["cache-control"] == "no-store" assert image.mode == "RGB" assert image.size == (64, 64) assert image.getpixel((2, 7)) == (12, 34, 56) assert client.get("/api/status").json()["state"] == before def test_animation_playback_api_controls_frame_and_rejects_invalid_or_stale_sessions(tmp_path): app = create_app(project_root=tmp_path, driver_kind="mock") with TestClient(app) as client: animation = client.post("/api/animations", json={"name": "播放控制"}).json() first = client.post( f"/api/animations/{animation['id']}/frames", headers={"If-Match": f'"{animation["revision"]}"'}, json={"scene": solid_scene((255, 0, 0)), "duration_ms": 200}, ).json() second = client.post( f"/api/animations/{animation['id']}/frames", headers={"If-Match": f'"{first["revision"]}"'}, json={"scene": solid_scene((0, 255, 0)), "duration_ms": 300}, ).json() played = client.post( f"/api/animations/{animation['id']}/play", headers={"If-Match": f'"{second["revision"]}"'}, ) assert played.status_code == 200 playback = client.get("/api/status").json()["state"]["animation_playback"] session_id = playback["session_id"] assert playback["frame_count"] == 2 assert playback["total_duration_ms"] == 500 controlled = client.patch( "/api/display/animation-playback", json={ "session_id": session_id, "position_ms": 250, "paused": True, "speed": 1.5, }, ) assert controlled.status_code == 200 assert controlled.json()["state_revision"] >= 1 result = controlled.json()["animation_playback"] assert result["position_ms"] == 250 assert result["frame_index"] == 2 assert result["paused"] is True assert result["speed"] == 1.5 frame = Image.open(BytesIO(client.get("/api/display/current-frame").content)) assert frame.getpixel((0, 0)) == (0, 255, 0) invalid_payloads = [ {"session_id": session_id}, {"session_id": session_id, "position_ms": 500}, {"session_id": session_id, "position_ms": True}, {"session_id": session_id, "paused": 1}, {"session_id": session_id, "speed": 1.25}, {"session_id": session_id, "speed": "2"}, {"session_id": session_id, "paused": None}, {"session_id": session_id, "paused": True, "unknown": 1}, ] for payload in invalid_payloads: assert client.patch("/api/display/animation-playback", json=payload).status_code == 422 replayed = client.post( f"/api/animations/{animation['id']}/play", headers={"If-Match": f'"{second["revision"]}"'}, ) assert replayed.status_code == 200 reset = client.get("/api/status").json()["state"]["animation_playback"] assert reset["session_id"] != session_id assert reset["position_ms"] < 100 assert reset["paused"] is False assert reset["speed"] == 1.0 stale = client.patch( "/api/display/animation-playback", json={"session_id": session_id, "paused": True}, ) assert stale.status_code == 409 def test_create_app_explicit_project_root_ignores_storage_environment( tmp_path, monkeypatch, ): environment_data = tmp_path / "environment-data" environment_runtime = tmp_path / "environment-runtime" monkeypatch.setenv("MATRIX_DATA_DIR", str(environment_data)) monkeypatch.setenv("STATE_DIRECTORY", str(tmp_path / "state-data")) monkeypatch.setenv("MATRIX_RUNTIME_DIR", str(environment_runtime)) monkeypatch.setenv("RUNTIME_DIRECTORY", str(tmp_path / "systemd-runtime")) explicit = tmp_path / "explicit" app = create_app(project_root=explicit, driver_kind="mock") assert app.state.config_store.data_dir == explicit / "data" assert app.state.config_store.runtime_dir == explicit / "data" / "runtime" assert not environment_data.exists() assert not environment_runtime.exists() def test_consecutive_brightness_updates_keep_config_status_and_driver_in_sync(tmp_path): app = create_app(project_root=tmp_path, driver_kind="mock") client = TestClient(app) for value in (20, 55, 37): response = client.put("/api/config", json={"brightness": value}) assert response.status_code == 200 assert response.json()["brightness"] == value status = client.get("/api/status") assert status.status_code == 200 assert status.json()["state"]["brightness"] == 37 assert status.json()["screen"]["driver_options"]["brightness"] == 37 assert app.state.config_store.config["brightness"] == 37 def test_voltage_status_calibration_and_reset_do_not_change_display_state(tmp_path): class Device: def __enter__(self): return self def __exit__(self, exc_type, exc, traceback): return None def configure(self): return None def read_fresh(self): return 13115, 0x0C app = create_app( project_root=tmp_path, driver_kind="mock", voltage_monitor_factory=lambda store: VoltageMonitor(store, device_factory=Device), ) client = TestClient(app) app.state.voltage_monitor.sample_now() state_before = client.get("/api/status").json()["state"] invalid = client.post( "/api/power/voltage/calibration/preview", json={"reference_volts": 4.0}, ) assert invalid.status_code == 400 preview = client.post( "/api/power/voltage/calibration/preview", json={"reference_volts": 5.0}, ) assert preview.status_code == 200 proposal = preview.json() assert proposal["uncalibrated_volts"] > 4.9 assert client.get("/api/config").json()["voltage_calibrated_at"] is None confirmed = client.post( "/api/power/voltage/calibration/confirm", json={"proposal_id": proposal["proposal_id"]}, ) assert confirmed.status_code == 200 assert confirmed.json()["screen_input_voltage"]["calibrated"] is True assert confirmed.json()["config"]["voltage_calibrated_at"].endswith("Z") assert client.post( "/api/power/voltage/calibration/confirm", json={"proposal_id": proposal["proposal_id"]}, ).status_code == 409 reset = client.post("/api/power/voltage/calibration/reset", json={}) assert reset.status_code == 200 assert reset.json()["config"]["voltage_calibration_factor"] == 1.0 assert reset.json()["config"]["voltage_calibrated_at"] is None assert client.get("/api/status").json()["state"] == state_before def test_low_voltage_protection_api_overlays_without_changing_user_state(tmp_path): class MutableDevice: raw = 11777 # Approximately 4.49V with the Unit ADC divider. def __enter__(self): return self def __exit__(self, exc_type, exc, traceback): return None def configure(self): return None def read_fresh(self): return self.raw, 0x0C device = MutableDevice() app = create_app( project_root=tmp_path, driver_kind="mock", voltage_monitor_factory=lambda store: VoltageMonitor( store, device_factory=lambda: device, ), ) client = TestClient(app) enabled = client.put( "/api/config", json={"low_voltage_protection_enabled": True}, ) assert enabled.status_code == 200 assert enabled.json()["low_voltage_protection_enabled"] is True critical = client.get("/api/status").json() assert critical["power"]["low_voltage_protection"]["mode"] == "critical" assert critical["power"]["low_voltage_protection"]["brightness_limit_percent"] == 35 assert critical["power"]["low_voltage_protection"]["sampling_interval_seconds"] == 0.5 assert critical["state"]["mode"] == "low_voltage_indicator" assert critical["state"]["brightness"] == 40 assert critical["state"]["effective_brightness"] == 35 assert critical["state"]["revision"] == 0 indicator = Image.open(BytesIO(client.get("/api/display/current-frame").content)) assert {color for _count, color in indicator.getcolors(maxcolors=4096)} == { (0, 0, 0), (255, 0, 0), } assert client.post("/api/display/fill", json={"color": "#00FF00"}).status_code == 200 assert client.put( "/api/config", json={"orientation": 90, "brightness": 77}, ).status_code == 200 while_critical = client.get("/api/status").json() assert while_critical["state"]["mode"] == "low_voltage_indicator" assert while_critical["state"]["brightness"] == 77 assert while_critical["state"]["effective_brightness"] == 35 assert app.state.config_store.config["last_mode"] == "fill" user_revision = while_critical["state"]["revision"] protection_revision = while_critical["power"]["low_voltage_protection"]["revision"] device.raw = 12853 # Approximately 4.90V. app.state.voltage_monitor.sample_now() first_recovery = client.get("/api/status").json() assert first_recovery["power"]["low_voltage_protection"]["mode"] == "critical" app.state.voltage_monitor.sample_now() recovered = client.get("/api/status").json() assert recovered["power"]["low_voltage_protection"]["mode"] == "inactive" assert recovered["power"]["low_voltage_protection"]["revision"] > protection_revision assert recovered["state"]["revision"] == user_revision assert recovered["state"]["mode"] == "fill" assert recovered["state"]["brightness"] == 77 assert recovered["state"]["effective_brightness"] == 77 restored = Image.open(BytesIO(client.get("/api/display/current-frame").content)) assert {color for _count, color in restored.getcolors(maxcolors=4096)} == { (0, 255, 0), } def test_startup_sample_applies_critical_overlay_before_smile(tmp_path): class CriticalDevice: def __enter__(self): return self def __exit__(self, exc_type, exc, traceback): return None def configure(self): return None def read_fresh(self): return 11777, 0x0C ConfigStore(project_root=tmp_path).update({ "low_voltage_protection_enabled": True, "last_mode": "fill", }) app = create_app( project_root=tmp_path, driver_kind="mock", startup_boot_id="low-voltage-startup", voltage_monitor_factory=lambda store: VoltageMonitor( store, device_factory=CriticalDevice, ), ) with TestClient(app) as client: status = client.get("/api/status").json() assert status["power"]["low_voltage_protection"]["mode"] == "critical" assert status["state"]["mode"] == "low_voltage_indicator" assert status["state"]["effective_brightness"] == 35 assert status["state"]["startup_indicator_active"] is False assert status["state"]["animation_active"] is True assert status["state"]["current_content"] == { "category": "system", "id": None, "name": "低电压提示", } assert app.state.config_store.config["last_mode"] == "fill" def test_bad_color_returns_400(tmp_path): client = TestClient(create_app(project_root=tmp_path, driver_kind="mock")) response = client.post("/api/display/fill", json={"color": "green"}) assert response.status_code == 400 def test_shared_color_palette_api(tmp_path): app = create_app(project_root=tmp_path, driver_kind="mock") app.state.config_store.update({"color_palette": []}) client = TestClient(app) added = client.post("/api/colors/palette", json={"color": "#aabbcc"}) assert added.status_code == 200 assert added.json() == {"colors": ["#AABBCC"], "limit": 24} duplicate = client.post("/api/colors/palette", json={"color": "#AABBCC"}) assert duplicate.status_code == 200 assert duplicate.json()["colors"] == ["#AABBCC"] invalid = client.post("/api/colors/palette", json={"color": "blue"}) assert invalid.status_code == 400 deleted = client.delete("/api/colors/palette/AABBCC") assert deleted.status_code == 200 assert deleted.json()["colors"] == [] assert client.delete("/api/colors/palette/AABBCC").status_code == 200 def test_shared_color_palette_limit(tmp_path): app = create_app(project_root=tmp_path, driver_kind="mock") app.state.config_store.update({"color_palette": []}) client = TestClient(app) for index in range(24): response = client.post("/api/colors/palette", json={"color": f"#{index:06X}"}) assert response.status_code == 200 overflow = client.post("/api/colors/palette", json={"color": "#FFFFFF"}) assert overflow.status_code == 409 def test_canvas_websocket(tmp_path): app = create_app(project_root=tmp_path, driver_kind="mock") client = TestClient(app) legacy_frame = bytes([0, 0, 0]) * (64 * 64) composition_frame = bytes([18, 52, 86]) * (64 * 64) with client.websocket_connect("/ws/canvas") as websocket: websocket.send_json({ "type": "frame_rgb", "width": 64, "height": 64, "encoding": "base64_rgb888", "data": base64.b64encode(legacy_frame).decode("ascii"), }) assert websocket.receive_json() == {"type": "ack", "mode": "canvas", "applied": True} assert app.state.config_store.config["last_mode"] == "canvas" websocket.send_json({ "type": "frame_rgb", "source": "composition", "width": 64, "height": 64, "encoding": "base64_rgb888", "data": base64.b64encode(composition_frame).decode("ascii"), }) assert websocket.receive_json() == {"type": "ack", "mode": "composition", "applied": True} assert app.state.config_store.config["last_mode"] == "composition" frame_path = tmp_path / "data" / "runtime" / "last_frame.png" frame_before_error = frame_path.read_bytes() websocket.send_json({ "type": "frame_rgb", "source": "canvas", "width": 64, "height": 64, "encoding": "base64_rgb888", "data": base64.b64encode(legacy_frame).decode("ascii"), }) error = websocket.receive_json() assert error["type"] == "error" assert "source" in error["message"] assert app.state.config_store.config["last_mode"] == "composition" assert frame_path.read_bytes() == frame_before_error websocket.send_json({"type": "frame_rgb", "width": 1, "height": 1, "encoding": "base64_rgb888", "data": ""}) assert websocket.receive_json()["type"] == "error" def test_preview_endpoints_return_png_without_display_side_effects(tmp_path): client = TestClient(create_app(project_root=tmp_path, driver_kind="mock")) fill = client.post("/api/display/fill", json={"color": "#123456"}) assert fill.status_code == 200 frame_path = tmp_path / "data" / "runtime" / "last_frame.png" frame_before = frame_path.read_bytes() status_before = client.get("/api/status").json()["state"] text_preview = client.post("/api/preview/text", json={ "text": "UP", "size": 12, "x": 4, "y": 24, "color": "#FFFFFF", "background": "#000000", }) assert text_preview.status_code == 200 assert text_preview.headers["content-type"] == "image/png" text_image = Image.open(BytesIO(text_preview.content)) assert text_image.size == (64, 64) assert text_image.mode == "RGB" text_layer_preview = client.post("/api/preview/text-layer", json={ "text": "TOP", "size": 12, "x": 4, "y": 24, "color": "#FF8000", }) assert text_layer_preview.status_code == 200 assert text_layer_preview.headers["content-type"] == "image/png" text_layer_image = Image.open(BytesIO(text_layer_preview.content)) assert text_layer_image.size == (64, 64) assert text_layer_image.mode == "RGBA" assert text_layer_image.getpixel((63, 63)) == (0, 0, 0, 0) assert text_layer_image.getchannel("A").getbbox() is not None diagnostic_preview = client.post("/api/preview/diagnostic", json={"mode": "corners_lines"}) assert diagnostic_preview.status_code == 200 diagnostic_image = Image.open(BytesIO(diagnostic_preview.content)) assert diagnostic_image.size == (64, 64) assert diagnostic_image.getpixel((0, 0)) == (255, 0, 0) assert client.get("/api/status").json()["state"] == status_before assert frame_path.read_bytes() == frame_before def test_multilingual_text_preview_uses_covered_fonts_and_rejects_emoji(tmp_path): client = TestClient(create_app(project_root=tmp_path, driver_kind="mock")) samples = ("你好", "日本", "한글", "Привет", "Ελλάδα", "مرحبا", "שלום", "สวัสดี") for sample in samples: response = client.post("/api/preview/text-layer", json={ "text": sample, "font": "default", "size": 18, "x": 0, "y": 16, "color": "#FFFFFF", }) assert response.status_code == 200, (sample, response.text) image = Image.open(BytesIO(response.content)) assert image.mode == "RGBA" assert image.getchannel("A").getbbox() is not None unsupported = client.post("/api/preview/text-layer", json={ "text": "😀", "font": "default", "size": 18, "x": 0, "y": 16, "color": "#FFFFFF", }) assert unsupported.status_code == 400 assert "U+1F600" in unsupported.json()["detail"] def test_composition_can_be_reapplied_after_diagnostic_override(tmp_path): app = create_app(project_root=tmp_path, driver_kind="mock") client = TestClient(app) composition_frame = bytearray([0, 0, 0] * (64 * 64)) composition_frame[0:3] = bytes((12, 34, 56)) composition_frame[-3:] = bytes((78, 90, 123)) encoded_frame = base64.b64encode(composition_frame).decode("ascii") with client.websocket_connect("/ws/canvas") as websocket: websocket.send_json({ "type": "frame_rgb", "source": "composition", "width": 64, "height": 64, "encoding": "base64_rgb888", "data": encoded_frame, }) assert websocket.receive_json()["mode"] == "composition" diagnostic = client.post("/api/display/diagnostic", json={"mode": "corners_lines"}) assert diagnostic.status_code == 200 assert app.state.config_store.config["last_mode"] == "diagnostic:corners_lines" diagnostic_image = Image.open(tmp_path / "data" / "runtime" / "last_frame.png") assert diagnostic_image.getpixel((0, 0)) == (255, 0, 0) with client.websocket_connect("/ws/canvas") as websocket: websocket.send_json({ "type": "frame_rgb", "source": "composition", "width": 64, "height": 64, "encoding": "base64_rgb888", "data": encoded_frame, }) assert websocket.receive_json() == { "type": "ack", "mode": "composition", "applied": True, } restored = Image.open(tmp_path / "data" / "runtime" / "last_frame.png") assert restored.tobytes() == bytes(composition_frame) assert app.state.config_store.config["last_mode"] == "composition" def test_mobile_workspace_assets_are_served(tmp_path): app = create_app(project_root=tmp_path, driver_kind="mock") client = TestClient(app) index = client.get("/") assert index.status_code == 200 assert index.headers["cache-control"] == "no-store" assert f'' in index.text assert f'/static/{app.state.app_version}/style.css' in index.text assert f'/static/{app.state.app_version}/app.js' in index.text assert 'id="workspace-drawer"' in index.text assert 'id="workspace-order-toggle"' in index.text assert "自定义项目位置" in index.text assert 'id="workspace-settings"' in index.text assert 'id="topbar-voltage"' in index.text assert 'id="start-voltage-calibration"' in index.text assert 'id="voltage-calibration-dialog"' in index.text assert 'id="board-stage"' in index.text assert 'id="scene-canvas"' in index.text assert 'id="pixel-grid"' in index.text assert 'id="pixel-edit-toggle"' in index.text assert 'id="pixel-edit-status"' in index.text assert 'id="text-element-list"' in index.text assert 'id="create-text"' in index.text assert 'id="workspace-templates"' in index.text assert 'id="workspace-animations"' in index.text assert 'id="animation-frames"' in index.text assert 'id="animation-edit-notice"' in index.text assert 'id="animation-preview-image"' in index.text assert 'id="animation-board-preview"' in index.text assert 'id="animation-dirty-message"' in index.text assert 'id="library-copy-dialog"' in index.text assert 'id="template-grid"' in index.text assert 'id="save-template"' in index.text assert 'id="template-dialog-note"' in index.text assert 'id="animation-apply-all"' not in index.text assert 'id="template-confirm-dialog"' in index.text assert 'id="config-font"' not in index.text assert 'id="text-font"' in index.text assert 'role="combobox"' in index.text assert 'id="import-font"' in index.text assert 'accept=".ttf,.otf,.ttc,.otc"' in index.text assert "选择设备已有字体,或导入 TTF、OTF、TTC、OTC 文件" in index.text assert 'id="text-background"' not in index.text assert 'type="color"' not in index.text assert index.text.count("data-color-picker") == 4 settings = index.text[index.text.index('id="workspace-settings"'):] assert settings.index("

屏幕亮度

") < settings.index("

屏幕方向

") < settings.index("

供电电压

") < settings.index("

预览刷新间隔

") assert 'id="preview-refresh-interval"' in settings assert "刷新间隔过短,会增加核桃派负载,可能影响系统运行。" in settings assert "角度仅作为4个方向的标识符,和现实方向无关,设置到实际方向正确之后自动储存在系统内。" in settings assert "设备的输入电压应该为5V,稍微低一点是正常的。但如果严重偏低请充电。" in settings assert '

性能模式

' in settings assert "如果使用出现卡顿,可以打开此选项,但会降低续航。" in settings assert settings.index('

性能模式

') < settings.index("

模板界面同时播放的动图数量

") assert 'id="animation-preview-max-concurrent"' in settings assert 'min="1"' in settings[settings.index('id="animation-preview-max-concurrent"'):] assert 'max="50"' in settings[settings.index('id="animation-preview-max-concurrent"'):] assert "不建议开启太多,否则会出现严重的性能问题。" in settings assert '

frp配置

' in settings assert "此功能通常是远程排查使用,请保证你知道你在做什么。" in settings assert settings.index('

frp配置

') > settings.index('id="animation-preview-max-concurrent"') assert "方向和亮度立即应用并保存;电压传感器按设备独立校准。" not in settings assert "更换 ADC 模块、测量线或测量点后,请恢复标称值并重新校准。" not in settings assert "方向和亮度会自动保存" not in settings legacy = client.get("/static/core.js") assert legacy.status_code == 200 assert legacy.headers["cache-control"] == "no-store" assert client.get("/static/scene-model.js").status_code == 200 scene_board = client.get("/static/scene-board.js") assert scene_board.status_code == 200 assert 'new Set(["device", "settings", "templates"])' in scene_board.text assert client.get("/static/template-edit-model.js").status_code == 200 assert ( 'renderUiCopy("copy.dynamic.board.monitor_mode", boardMode, ' '"预览当前显示屏幕画面", {}, { defaultHidden: true })' in scene_board.text ) assert 'renderUiValue(boardMode, "")' in scene_board.text assert "当前设备逻辑画面 · 只读监视" not in scene_board.text assert client.get("/static/color-math.js").status_code == 200 canvas_tools = client.get("/static/canvas-tools-model.js") assert canvas_tools.status_code == 200 assert 'presets: Object.freeze([1, 2, 3, 4])' in canvas_tools.text assert client.get("/static/color-picker.js").status_code == 200 assert client.get("/static/views/templates.js").status_code == 200 assert client.get("/static/views/animations.js").status_code == 200 assert client.get("/static/animation-ui-model.js").status_code == 200 assert client.get("/static/sortable-card.js").status_code == 200 interaction_feedback = client.get("/static/interaction-feedback.js") assert interaction_feedback.status_code == 200 assert 'setAttribute("aria-disabled", "true")' in interaction_feedback.text assert 'setAttribute("aria-busy", "true")' in interaction_feedback.text device_view = client.get("/static/views/device.js") assert device_view.status_code == 200 assert "/api/preview/diagnostic" not in device_view.text assert "showTestFill" not in device_view.text assert 'limiting: "电压过低,屏幕亮度限制"' in device_view.text assert 'critical: "电压过低,屏幕已经禁用,请充电"' in device_view.text settings_view = client.get("/static/views/settings.js") assert settings_view.status_code == 200 assert "JSON.stringify({ low_voltage_protection_enabled: requested })" in settings_view.text assert "showLowVoltageProtectionSwitch(previous)" in settings_view.text assert "低电压亮度保护保存失败" in settings_view.text assert "JSON.stringify({ performance_mode_enabled: requested })" in settings_view.text assert "性能模式切换失败" in settings_view.text assert "JSON.stringify({ animation_preview_max_concurrent: requested })" in settings_view.text assert "同时播放数量保存失败" in settings_view.text protection_sync = settings_view.text.index( "lowVoltageProtectionStatus = detail.power?.low_voltage_protection || null;" ) pending_early_return = settings_view.text.index( "if (orientationPending || applyingBrightness || desiredBrightness !== null) return;" ) assert protection_sync < pending_early_return assert "保护开关已从其他页面同步为开启" in settings_view.text assert "当前允许的最高亮度:${limitText}%" in settings_view.text versioned_app = client.get(f"/static/{app.state.app_version}/app.js") assert versioned_app.status_code == 200 assert versioned_app.headers["cache-control"] == "public, max-age=31536000, immutable" assert 'from "./core.js"' in versioned_app.text assert client.get(f"/static/{app.state.app_version}/views/device.js").status_code == 200 assert client.get(f"/static/{app.state.app_version}/canvas-tools-model.js").status_code == 200 assert client.get(f"/static/{app.state.app_version}/interaction-feedback.js").status_code == 200 status = client.get("/api/status") assert status.headers["cache-control"] == "no-store" assert status.json()["service"]["app_version"] == app.state.app_version assert status.json()["service"]["instance_id"] == app.state.instance_id assert status.json()["state"]["revision"] == 0 def test_app_lifecycle_starts_default_animation_and_screen_write_replaces_it(tmp_path): app = create_app(project_root=tmp_path, driver_kind="mock", startup_boot_id="boot-api") with TestClient(app) as client: status = client.get("/api/status").json() config = client.get("/api/config").json() assert status["state"]["mode"] == "animation" assert status["state"]["startup_indicator_active"] is False assert status["state"]["animation_active"] is True assert status["state"]["current_content"] == { "category": "animation", "id": DEMO_ANIMATION_ID, "name": DEMO_ANIMATION_NAME, } assert status["state"]["brightness"] == config["brightness"] assert status["state"]["effective_brightness"] == config["brightness"] preview = client.post("/api/preview/diagnostic", json={"mode": "corners_lines"}) assert preview.status_code == 200 assert client.get("/api/status").json()["state"]["animation_active"] is True invalid = client.post("/api/display/fill", json={"color": "not-a-color"}) assert invalid.status_code == 400 assert client.get("/api/status").json()["state"]["animation_active"] is True applied = client.post("/api/display/clear") assert applied.status_code == 200 final_status = client.get("/api/status").json() assert final_status["state"]["startup_indicator_active"] is False assert final_status["state"]["animation_active"] is False assert final_status["state"]["effective_brightness"] == config["brightness"] assert final_status["state"]["mode"] == "clear" assert final_status["state"]["current_content"]["name"] == "未保存内容" def test_maintenance_black_startup_skips_default_content_and_boot_overlay(tmp_path): app = create_app( project_root=tmp_path, driver_kind="mock", startup_boot_id="maintenance-black", maintenance_black=True, ) with TestClient(app) as client: status = client.get("/api/status").json() frame = Image.open(BytesIO(client.get("/api/display/current-frame").content)) assert app.state.maintenance_black is True assert status["state"]["mode"] == "clear" assert status["state"]["animation_active"] is False assert status["state"]["wifi_indicator_active"] is False assert frame.convert("RGB").getbbox() is None def test_default_content_api_applies_persists_and_validates_template_source(tmp_path): app = create_app(project_root=tmp_path, driver_kind="mock", startup_boot_id="default-api") with TestClient(app) as client: initial = client.get("/api/display/default-content") assert initial.status_code == 200 assert initial.json()["id"] == DEMO_ANIMATION_ID assert initial.json()["is_demo"] is True empty = client.post("/api/animations", json={"name": "空动图"}).json() rejected = client.put( "/api/display/default-content", json={"type": "animation", "id": empty["id"]}, ) assert rejected.status_code == 409 assert client.get("/api/display/default-content").json()["id"] == DEMO_ANIMATION_ID created = client.post( "/api/templates", json={"name": "默认静态图", "scene": solid_scene((120, 30, 10))}, ).json() selected = client.put( "/api/display/default-content", json={"type": "template", "id": created["id"]}, ) assert selected.status_code == 200 assert selected.json()["name"] == "默认静态图" state = client.get("/api/status").json()["state"] assert state["mode"] == "template" assert state["current_content"] == { "category": "template", "id": created["id"], "name": "默认静态图", } frame = bytes((120, 30, 10)) * (64 * 64) with client.websocket_connect("/ws/canvas") as websocket: websocket.send_json({ "type": "frame_rgb", "source": "composition", "width": 64, "height": 64, "encoding": "base64_rgb888", "data": base64.b64encode(frame).decode("ascii"), "content_source": { "type": "template", "id": created["id"], "revision": created["revision"], }, }) assert websocket.receive_json()["applied"] is True assert client.get("/api/status").json()["state"]["current_content"]["name"] == "默认静态图" updated = client.put( f"/api/templates/{created['id']}", headers={"If-Match": f'"{created["revision"]}"'}, json={"scene": solid_scene((1, 2, 3))}, ).json() assert updated["revision"] != created["revision"] with client.websocket_connect("/ws/canvas") as websocket: websocket.send_json({ "type": "frame_rgb", "source": "composition", "width": 64, "height": 64, "encoding": "base64_rgb888", "data": base64.b64encode(frame).decode("ascii"), "content_source": { "type": "template", "id": created["id"], "revision": created["revision"], }, }) assert websocket.receive_json()["applied"] is True assert client.get("/api/status").json()["state"]["current_content"] == { "category": "unsaved", "id": None, "name": "未保存内容", } with client.websocket_connect("/ws/canvas") as websocket: websocket.send_json({ "type": "frame_rgb", "source": "composition", "width": 64, "height": 64, "encoding": "base64_rgb888", "data": base64.b64encode(frame).decode("ascii"), "content_source": { "type": "template", "id": "not-a-uuid", "revision": created["revision"], }, }) assert websocket.receive_json()["type"] == "error" restarted = create_app(project_root=tmp_path, driver_kind="mock", startup_boot_id="default-api-restart") with TestClient(restarted) as client: assert client.get("/api/display/default-content").json()["id"] == created["id"] assert client.get("/api/status").json()["state"]["current_content"]["name"] == "默认静态图" def test_static_content_version_changes_with_paths_and_content(tmp_path): static_dir = tmp_path / "static" static_dir.mkdir() (static_dir / "app.js").write_text("one", encoding="utf-8") first = static_content_version(static_dir) assert first == static_content_version(static_dir) (static_dir / "app.js").write_text("two", encoding="utf-8") second = static_content_version(static_dir) assert second != first (static_dir / "nested").mkdir() (static_dir / "nested" / "app.js").write_text("two", encoding="utf-8") assert static_content_version(static_dir) != second def test_systemd_preserves_runtime_marker_across_service_stop_and_restart(): unit = (Path(__file__).resolve().parents[1] / "systemd" / "matrix-screen-controller.service").read_text(encoding="utf-8") assert "RuntimeDirectoryPreserve=yes" in unit assert "scripts/dedicated_host.py check --service" in unit assert "After=NetworkManager.service" in unit assert "Wants=NetworkManager.service" in unit assert "network-online.target" not in unit def test_wifi_settings_api_reveals_password_only_on_dedicated_get(tmp_path): connection_uuid = "12345678-1234-5678-1234-567812345678" backend = MockNetworkManager( saved={ "connection_uuid": connection_uuid, "ssid": "phone-hotspot", "password": "secret123", "password_configured": True, "ipv4_mode": "dhcp", "address": None, "prefix": None, "gateway": None, "dns_servers": [], }, active={ "connected": True, "connection_uuid": connection_uuid, "ssid": "phone-hotspot", "ipv4_address": "192.168.9.93", "control_url": "http://192.168.9.93:8080/", }, ) startup_activation = threading.Event() activate = backend.activate def record_activation(*args, **kwargs): result = activate(*args, **kwargs) startup_activation.set() return result backend.activate = record_activation app = create_app( project_root=tmp_path, driver_kind="mock", startup_boot_id="wifi-api", network_backend=backend, ) with TestClient(app) as client: initial = client.get("/api/network/wifi") assert initial.status_code == 200 assert initial.json()["saved"]["password_configured"] is True assert initial.json()["saved"]["password"] == "secret123" assert initial.json()["active"]["control_url"] == "http://192.168.9.93:8080/" status_network = client.get("/api/status").json()["network"] assert "password" not in status_network["saved"] assert startup_activation.wait(5), "boot network activation did not complete" startup_activation_count = len(backend.activations) payload = { "ssid": "phone-hotspot", "password": None, "ipv4_mode": "manual", "address": "192.168.9.94", "gateway": "192.168.9.1", "prefix": None, "dns_servers": [], "prompt_delay_seconds": 45, "activation": "next_boot", } deferred = client.put("/api/network/wifi", json=payload) assert deferred.status_code == 200 assert deferred.json()["activation"] == "next_boot" assert len(backend.activations) == startup_activation_count payload["activation"] = "immediate" immediate = client.put("/api/network/wifi", json=payload) assert immediate.status_code == 202 assert immediate.json()["activation"] == "immediate" assert len(backend.activations) == startup_activation_count + 1 assert backend.activations[-1] == connection_uuid assert "secret123" not in immediate.text def test_wifi_prompt_delay_api_is_independent_from_network_manager(tmp_path): connection_uuid = "12345678-1234-5678-1234-567812345678" backend = MockNetworkManager( saved={ "connection_uuid": connection_uuid, "ssid": "phone-hotspot", "password": "secret123", "password_configured": True, "ipv4_mode": "dhcp", "address": None, "prefix": None, "gateway": None, "dns_servers": [], }, active={ "connected": True, "connection_uuid": connection_uuid, "ssid": "phone-hotspot", "ipv4_address": "192.168.9.93", "control_url": "http://192.168.9.93:8080/", }, ) app = create_app( project_root=tmp_path, driver_kind="mock", startup_boot_id="wifi-delay-api", network_backend=backend, ) with TestClient(app) as client: before = client.get("/api/network/wifi").json() saved_before = dict(backend.saved) activations_before = list(backend.activations) response = client.put( "/api/network/wifi/prompt-delay", json={"prompt_delay_seconds": 45}, ) assert response.status_code == 200 assert response.json()["prompt_delay_seconds"] == 45 after = client.get("/api/network/wifi").json() assert after["prompt_delay_seconds"] == 45 assert after["prompt"]["deadline_monotonic"] == before["prompt"]["deadline_monotonic"] assert backend.saved == saved_before assert backend.activations == activations_before assert client.put( "/api/network/wifi/prompt-delay", json={"prompt_delay_seconds": 0}, ).status_code == 400 assert client.put( "/api/network/wifi/prompt-delay", json={"prompt_delay_seconds": True}, ).status_code == 422 def test_homepage_dismisses_wifi_prompt_for_current_boot(tmp_path): app = create_app(project_root=tmp_path, driver_kind="mock", startup_boot_id="wifi-home") with TestClient(app) as client: before = client.get("/api/network/wifi").json() assert before["prompt"]["dismissed_for_boot"] is False assert client.get("/api/status").json()["state"]["current_content"]["id"] == DEMO_ANIMATION_ID app.state.display_service.show_wifi_indicator( connected=True, message="SSID: test IP: http://192.168.1.20:8080/", ) assert client.get("/api/status").json()["state"]["current_content"] == { "category": "system", "id": None, "name": "WiFi 提示", } assert client.get("/").status_code == 200 after = client.get("/api/network/wifi").json() assert after["prompt"]["dismissed_for_boot"] is True restored = client.get("/api/status").json()["state"] assert restored["current_content"]["id"] == DEMO_ANIMATION_ID assert restored["animation_active"] is True def test_device_view_hides_hardware_diagnostics_and_uses_temporary_test_api(): source = ( Path(__file__).resolve().parents[1] / "app" / "static" / "views" / "device.js" ).read_text(encoding="utf-8") assert "disable_hardware_pulsing" not in source assert "/api/display/diagnostic" not in source assert "/api/display/test/fill" in source assert "/api/display/test/brightness" in source assert "/api/device-storage" in source def test_settings_view_exposes_transactional_refresh_rate_buttons(): static_root = Path(__file__).resolve().parents[1] / "app" / "static" source = (static_root / "views" / "settings.js").read_text(encoding="utf-8") html = (static_root / "index.html").read_text(encoding="utf-8") assert html.count("data-matrix-refresh-rate=") == 7 assert "设置屏幕的最高扫描频率,不建议低于80Hz。更低的频率可以大大降低功耗,但会导致屏幕闪烁。" in html assert "matrix_refresh_rate_limit_hz" in source assert "扫描刷新率应用失败" in source