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

287 lines
13 KiB
Python

from __future__ import annotations
import json
from pathlib import Path
import re
from uuid import uuid4
import pytest
from fastapi.testclient import TestClient
from app.main import create_app
from app.ui_copy_editor.store import UiCopyDraftStore, UiCopyDraftValidationError
from app.ui_copy_editor.settings import UI_COPY_EDITOR_BUILD_ENABLED
from scripts.build_ui_copy_catalog import build_catalog, canonical_bytes
from scripts.migrate_ui_copy_draft import build_migration
def _put_body(document: dict) -> dict:
return {
"schema_version": document["schema_version"],
"base_catalog_digest": document["base_catalog_digest"],
"revision": document["revision"],
"updated_at": document["updated_at"],
"overrides": document["overrides"],
"insertions": document["insertions"],
}
def _special_client(tmp_path: Path) -> tuple[TestClient, object]:
app = create_app(tmp_path, driver_kind="mock", ui_copy_editor_build_enabled=True)
return TestClient(app), app
def test_generated_ui_copy_catalog_is_current_and_covers_static_and_dynamic_copy():
static_root = Path(__file__).resolve().parents[1] / "app" / "static"
catalog_path = static_root / "ui-copy.json"
catalog = json.loads(catalog_path.read_text(encoding="utf-8"))
assert catalog_path.read_bytes() == canonical_bytes(build_catalog())
assert catalog["schema_version"] == 2
assert len(catalog["items"]) >= 200
assert len(catalog["anchors"]) >= 100
assert any(item["kind"] == "html_text" for item in catalog["items"].values())
assert any(item["kind"] == "dynamic_template" and item["placeholders"] for item in catalog["items"].values())
assert {item["render"]["type"] for item in catalog["items"].values()} == {
"static_text", "attribute", "dynamic_template"
}
assert catalog["items"]["copy.dynamic.device.voltage_ok"]["placeholders"] == ["volts"]
assert "copy.700c9d89e165f685" not in catalog["items"]
sources = {item["source"] for item in catalog["items"].values()}
assert not any("#brightness-value::text" in source for source in sources)
assert not any("#topbar-cpu::text" in source for source in sources)
finalized_text = {
"copy.267697d7d5b31a20": "WiFi设置",
"copy.2c783f3068fc5ac0": "测试亮度(此亮度独立于屏幕亮度)",
"copy.5378fd8ef58a2368": "拖动时自动应用,松手后立即保存。(使用电池时,不建议亮度长期高于70)",
"copy.623419ad30e1d25e": "亮度(实际亮度会受到电压保护功能限制)",
"copy.6da7a87e7c173f8d": "设置屏幕的最高扫描频率,不建议低于80Hz。更低的频率可以大大降低功耗,但会导致屏幕闪烁。",
"copy.71922de473a7c23f": "保存的动图",
"copy.8556f6cbbb523cd9": "屏幕方向(设定值和现实角度无关,仅为软件内部旋转值)",
"copy.b8aeabe524848619": "屏幕方向(设定值和现实角度无关,仅为软件内部旋转值)",
"copy.bc1387b9107d6c7d": "仅为程序设定值,屏幕实际情况软件无法得知",
"copy.da499f45166370a8": "角度仅作为4个方向的标识符,和现实方向无关,设置到实际方向正确之后自动储存在系统内。",
"copy.df7f55d47d7a4ee5": "立即生效(会导致网页断开)",
"copy.dfda56f2b2367ca8": "设备会自动连接保存的WiFi。如果一段时间没有连接,会显示设备保存的WiFi信息,请开启对应WiFi用于连接设备。设备连接WiFi后,如果没有进入网页端就会显示对应ip",
"copy.e26968d8a3ff29f3": "全黑(不亮)",
"copy.ec993108f9022830": "开启后,电压降低时会限制实际亮度;严重欠压时会临时屏蔽用户画面。限制亮度解除之后恢复到用户设置的亮度",
"copy.f86b6371b88f559a": "设备的输入电压应该为5V,稍微低一点是正常的。但如果严重偏低请充电。",
"copy.ff765a69c8b49cee": "全白(高亮度会导致严重耗电)",
}
finalized_hidden = {
"copy.0a2bacf6cf154355", "copy.18abf73b22c104c1", "copy.209c8a51007fadfb",
"copy.4a2ff24bc62e26ef", "copy.55db93bad76b7e74", "copy.56f1193ee4a7192d",
"copy.5e26a038197f59b8", "copy.62029c969ffb9c9d", "copy.670c95eddb9bac43",
"copy.99050fd7437476f6", "copy.b0e7bd7e96cbe4d3", "copy.d9541ebf887002a5",
"copy.dynamic.board.composition_eyebrow", "copy.dynamic.board.composition_mode",
"copy.dynamic.board.composition_title", "copy.dynamic.board.monitor_eyebrow",
"copy.dynamic.board.monitor_mode", "copy.efdd13bfd5037544",
}
assert {copy_id: catalog["items"][copy_id]["text"] for copy_id in finalized_text} == finalized_text
assert all(catalog["items"][copy_id]["render"].get("default_hidden") is True for copy_id in finalized_hidden)
assert len(finalized_text) + len(finalized_hidden) == 34
def test_normal_build_has_no_editor_entrypoints(tmp_path):
assert UI_COPY_EDITOR_BUILD_ENABLED is False
app = create_app(tmp_path, driver_kind="mock")
client = TestClient(app)
home = client.get("/")
assert home.status_code == 200
assert "__UI_COPY_EDITOR_BUILD__" not in home.text
assert "ui-copy-editor" not in home.text
assert client.get("/api/dev/ui-copy").status_code == 404
assert client.put("/api/dev/ui-copy", json={}).status_code == 404
assert client.get("/api/dev/ui-copy/export").status_code == 404
assert client.get("/__ui-copy-editor/not-mounted/editor.js").status_code == 404
def test_special_build_serves_versioned_editor_and_persists_revisioned_draft(tmp_path):
client, app = _special_client(tmp_path)
home = client.get("/")
assert home.status_code == 200
assert "__UI_COPY_EDITOR_BUILD__" in home.text
assert home.text.index("__UI_COPY_EDITOR_BUILD__") < home.text.index(f'/static/{app.state.app_version}/app.js')
assert "ui-copy-runtime.js" in home.text
script_match = re.search(r'src="(?P<url>/__ui-copy-editor/[^"]+/editor\.js)"', home.text)
assert script_match
asset = client.get(script_match.group("url"))
assert asset.status_code == 200
assert asset.headers["cache-control"] == "public, max-age=31536000, immutable"
initial_response = client.get("/api/dev/ui-copy")
initial = initial_response.json()
assert initial_response.headers["etag"] == '"0"'
assert initial["revision"] == 0
assert initial["overrides"] == {}
assert initial["insertions"] == []
assert initial["catalog_mismatch"] is False
assert client.put("/api/dev/ui-copy", json=_put_body(initial)).status_code == 428
copy_id, item = next(
(key, value)
for key, value in app.state.ui_copy_store.items.items()
if value["kind"] == "html_text" and not value["placeholders"]
)
anchor_id, anchor = next(iter(app.state.ui_copy_store.anchors.items()))
body = _put_body(initial)
body["overrides"] = {
copy_id: {"text": item["text"] + "(测试)", "font_size_px": 18, "hidden": False}
}
body["insertions"] = [{
"id": str(uuid4()),
"workspace_id": anchor["scope"],
"anchor_id": anchor_id,
"placement": "after",
"text": "临时新增提示",
"font_size_px": 16,
}]
saved_response = client.put("/api/dev/ui-copy", headers={"If-Match": '"0"'}, json=body)
assert saved_response.status_code == 200
saved = saved_response.json()
assert saved_response.headers["etag"] == '"1"'
assert saved["revision"] == 1
assert saved["overrides"] == body["overrides"]
assert saved["insertions"] == body["insertions"]
stale = client.put("/api/dev/ui-copy", headers={"If-Match": '"0"'}, json=body)
assert stale.status_code == 409
exported = client.get("/api/dev/ui-copy/export")
assert exported.status_code == 200
assert "attachment" in exported.headers["content-disposition"]
assert exported.json()["revision"] == 1
recreated = create_app(tmp_path, driver_kind="mock", ui_copy_editor_build_enabled=True)
reread = TestClient(recreated).get("/api/dev/ui-copy").json()
assert reread["revision"] == 1
assert reread["overrides"] == body["overrides"]
assert reread["insertions"] == body["insertions"]
@pytest.mark.parametrize(
"mutation, expected",
[
(lambda body, _store: body["overrides"].update({"copy.unknown": {"text": "x"}}), "unknown UI copy id"),
(lambda body, store: body["overrides"].update({next(iter(store.items)): {"font_size_px": 7}}), "font size"),
(lambda body, _store: body.update({"unknown": True}), "fields are invalid"),
],
)
def test_special_build_rejects_invalid_draft_without_writing(tmp_path, mutation, expected):
client, app = _special_client(tmp_path)
initial = client.get("/api/dev/ui-copy").json()
body = _put_body(initial)
mutation(body, app.state.ui_copy_store)
response = client.put("/api/dev/ui-copy", headers={"If-Match": '"0"'}, json=body)
assert response.status_code == 422
assert expected in response.json()["detail"]
assert not app.state.ui_copy_store.path.exists()
def test_dynamic_placeholders_must_keep_the_same_names_and_multiplicity(tmp_path):
client, app = _special_client(tmp_path)
initial = client.get("/api/dev/ui-copy").json()
copy_id, item = next(
(key, value)
for key, value in app.state.ui_copy_store.items.items()
if value["placeholders"]
)
body = _put_body(initial)
body["overrides"] = {copy_id: {"text": item["text"].replace("{" + item["placeholders"][0] + "}", "")}}
response = client.put("/api/dev/ui-copy", headers={"If-Match": '"0"'}, json=body)
assert response.status_code == 422
assert "placeholders changed" in response.json()["detail"]
def test_corrupt_or_future_ui_copy_draft_is_preserved_and_rejected(tmp_path):
source_catalog = Path(__file__).resolve().parents[1] / "app" / "static" / "ui-copy.json"
draft_path = tmp_path / "development" / "ui-copy-editor" / "draft.json"
draft_path.parent.mkdir(parents=True)
corrupt = b'{"schema_version":'
draft_path.write_bytes(corrupt)
with pytest.raises(UiCopyDraftValidationError, match="valid UTF-8 JSON"):
UiCopyDraftStore(tmp_path, source_catalog)
assert draft_path.read_bytes() == corrupt
future = {
"schema_version": 2,
"base_catalog_digest": "0" * 64,
"revision": 0,
"updated_at": None,
"overrides": {},
"insertions": [],
}
draft_path.write_text(json.dumps(future), encoding="utf-8")
before = draft_path.read_bytes()
with pytest.raises(UiCopyDraftValidationError, match="schema_version"):
UiCopyDraftStore(tmp_path, source_catalog)
assert draft_path.read_bytes() == before
def test_catalog_mismatch_allows_export_but_blocks_replacement(tmp_path):
source_catalog = Path(__file__).resolve().parents[1] / "app" / "static" / "ui-copy.json"
store = UiCopyDraftStore(tmp_path, source_catalog)
document = store.empty_document()
store.replace(document, expected_revision=0)
raw = json.loads(store.path.read_text(encoding="utf-8"))
raw["base_catalog_digest"] = "0" * 64
store.path.write_text(json.dumps(raw), encoding="utf-8")
mismatched = UiCopyDraftStore(tmp_path, source_catalog)
assert mismatched.status_document()["catalog_mismatch"] is True
with pytest.raises(Exception, match="catalog changed"):
mismatched.replace(raw, expected_revision=1)
def test_catalog_v1_to_v2_migration_retains_registered_copy_and_reports_removed_copy():
old_catalog = {
"schema_version": 1,
"items": {
"copy.keep": {"text": "方向"},
"copy.remove": {"text": "1"},
},
"anchors": {},
"insertions": [],
}
new_catalog = {
"schema_version": 2,
"items": {
"copy.keep": {
"text": "方向", "scope": "device", "source": "test",
"kind": "html_text", "placeholders": [],
"render": {"type": "static_text", "selector": "#direction", "text_index": 0},
},
},
"anchors": {},
"insertions": [],
}
old_bytes = canonical_bytes(old_catalog)
new_bytes = canonical_bytes(new_catalog)
import hashlib
draft = {
"schema_version": 1,
"base_catalog_digest": hashlib.sha256(old_bytes).hexdigest(),
"revision": 44,
"updated_at": "2026-08-05T04:38:54Z",
"overrides": {
"copy.keep": {"text": "屏幕方向(仅为软件设定)"},
"copy.remove": {"text": "误改按钮"},
},
"insertions": [],
}
migrated, report = build_migration(
draft, old_catalog, old_bytes, new_catalog, new_bytes,
excluded_copy_ids={"copy.remove"},
)
assert migrated["revision"] == 45
assert migrated["overrides"] == {"copy.keep": {"text": "屏幕方向(仅为软件设定)"}}
assert migrated["base_catalog_digest"] == hashlib.sha256(new_bytes).hexdigest()
assert report["removed_overrides"] == [{
"copy_id": "copy.remove",
"reason": "explicitly_excluded",
"override": {"text": "误改按钮"},
}]