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.a44cae35bcb5eebe", "copy.55db93bad76b7e74", "copy.56f1193ee4a7192d", "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", } 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) # The separate text workspace's title, eyebrow and angle were removed. assert len(finalized_text) + len(finalized_hidden) == 31 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/__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": "误改按钮"}, }]