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

1307 lines
54 KiB
Python

import base64
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_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": None,
}
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/",
},
)
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"]
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