287 lines
13 KiB
Python
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": "误改按钮"},
|
|
}]
|