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

1326 lines
55 KiB
Python

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-"<unsafe>&'
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 '<meta name="device-boot-id" content="boot-&quot;&lt;unsafe&gt;&amp;">' 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'<meta name="app-version" content="{app.state.app_version}">' 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("<h3>屏幕亮度</h3>") < settings.index("<h3>屏幕方向</h3>") < settings.index("<h3>供电电压</h3>") < settings.index("<h3>预览刷新间隔</h3>")
assert 'id="preview-refresh-interval"' in settings
assert "刷新间隔过短,会增加核桃派负载,可能影响系统运行。" in settings
assert "角度仅作为4个方向的标识符,和现实方向无关,设置到实际方向正确之后自动储存在系统内。" in settings
assert "设备的输入电压应该为5V,稍微低一点是正常的。但如果严重偏低请充电。" in settings
assert '<h3>性能模式</h3>' in settings
assert "如果使用出现卡顿,可以打开此选项,但会降低续航。" in settings
assert settings.index('<h3>性能模式</h3>') < settings.index("<h3>模板界面同时播放的动图数量</h3>")
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 '<h3>frp配置</h3>' in settings
assert "此功能通常是远程排查使用,请保证你知道你在做什么。" in settings
assert settings.index('<h3>frp配置</h3>') > 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