1307 lines
54 KiB
Python
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": "当前内核未提供 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/",
|
|
},
|
|
)
|
|
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
|