Improve Android app UI and controller flows

This commit is contained in:
2026-10-09 16:43:58 +08:00
parent c0e26b97fe
commit 608229a497
90 changed files with 8103 additions and 1133 deletions
@@ -60,4 +60,4 @@ def test_fixture_generates_distinct_complete_animations(tmp_path):
document = json.loads(
(root / "animations" / item["id"] / "animation.json").read_text(encoding="utf-8")
)
assert document["schema_version"] == 2
assert document["schema_version"] == 3
@@ -188,7 +188,7 @@ def test_animation_crud_reorder_duration_thumbnail_and_persistence(tmp_path):
persisted = TestClient(restarted).get(f"/api/animations/{created['id']}").json()
assert persisted["frame_count"] == 2
document = json.loads((tmp_path / "data" / "animations" / created["id"] / "animation.json").read_text(encoding="utf-8"))
assert document["schema_version"] == 2
assert document["schema_version"] == 3
assert all("scene" not in frame and "scene_file" in frame for frame in document["frames"])
assert len(list((tmp_path / "data" / "animations" / created["id"] / "scenes").glob("*.json"))) == 2
@@ -231,8 +231,8 @@ test("canvas editor markup, guarded input, and checkpoint events stay wired", as
assert.match(style, /\.canvas-coordinate-guide\s*\{[^}]*pointer-events:\s*none/s);
assert.match(style, /body\.canvas-edit-active \.canvas-coordinate-guide:not\(\[hidden\]\)\s*\{[^}]*z-index:\s*1005/s);
assert.match(style, /#scene-overlay\[data-interaction="pixels"\]:not\(\[data-canvas-editing="true"\]\)\s*\{[^}]*pointer-events:\s*none/s);
assert.match(canvasView, /if \(!editorActive \|\| getInteractionMode\(\) !== "pixels"\) return/);
assert.match(canvasView, /new PixelUndoHistory\(\)/);
assert.match(canvasView, /getInteractionMode\(\) === "readonly"/);
assert.match(canvasView, /undoSceneEdit\(\)/);
assert.match(canvasView, /createLocalState\("canvas-tools", 3\)/);
assert.match(canvasView, /coordinateGuide\.hidden = true/);
assert.match(sceneBoard, /matrix:scene-replaced/);
@@ -231,7 +231,7 @@ def test_v7_config_inserts_media_workspace_without_reordering_existing_items(tmp
migrated = ConfigStore(project_root=tmp_path)
assert migrated.config["workspace_order"] == [
"settings", "text", "templates", "media-import", "animations", "device", "canvas",
"settings", "templates", "media-import", "animations", "device", "canvas",
]
assert json.loads(migrated.path.read_text(encoding="utf-8"))["schema_version"] == CONFIG_SCHEMA_VERSION
@@ -263,7 +263,7 @@ def test_v9_config_migrates_animation_preview_concurrency_without_losing_values(
assert migrated.config["performance_mode_enabled"] is True
assert migrated.config["animation_preview_max_concurrent"] == 2
assert json.loads(migrated.path.read_text(encoding="utf-8"))["schema_version"] == 10
assert json.loads(migrated.path.read_text(encoding="utf-8"))["schema_version"] == 11
def test_config_schema_field_sets_are_explicit_and_incremental():
@@ -0,0 +1,77 @@
import test from "node:test";
import assert from "node:assert/strict";
import * as legacy from "../app/static/scene-model-v1.js";
import { encodePixels, decodePixels } from "../app/static/pixel-png.js";
import { attachScene, blankScene, createLayer, cloneLayer, composeScene, serializeScene, deserializeScene,
clipBrushPixels, translatedOverflow, textClipAt, clipTextPixels, addTextElement, updateTextElement } from "../app/static/scene-model.js";
import { restoreSceneDraft,LEGACY_STORAGE_KEYS } from "../app/static/scene-model.js";
function pixel(layer,x,y,color){layer.pixels.set(color,((y+64)*192+x+64)*4);layer.revision++;}
test("old scene migration rejects unknown fields and duplicate text without filtering",()=>{
const raw=legacy.sceneToStorageValue(legacy.addTextElement(legacy.createScene(),{text:"preserved"}));
assert.ok(deserializeScene(raw));
assert.equal(deserializeScene({...raw,unknown:true}),null);
assert.equal(deserializeScene({...raw,elements:[...raw.elements,...raw.elements]}),null);
assert.equal(deserializeScene({...raw,elements:[{...raw.elements[0],x:999}]}),null);
const values=new Map([[LEGACY_STORAGE_KEYS.sceneV1,JSON.stringify({...raw,unknown:true})]]);
const storage={getItem:key=>values.get(key)??null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)};
const before=[...values];
assert.equal(restoreSceneDraft(storage).canPersist,false);
assert.deepEqual([...values],before);
});
test("PNG compression round trips sparse and full brush pixels without DOM",()=>{
const data=new Uint8ClampedArray(192*192*4);data.set([1,2,3,255],400);data.set([5,6,7,128],data.length-4);
const png=encodePixels(data);assert.ok(png.length<3000);assert.deepEqual(decodePixels(png),data);
const corrupt=png.slice(0,-8)+"AAAAAAAA";assert.throws(()=>decodePixels(corrupt));
});
test("blank and black are distinct; independent backgrounds follow layer order",()=>{
const bottom=createLayer("brush"),top=createLayer("brush",[bottom]);bottom.background="#FF0000";
let scene=attachScene([bottom,top]);assert.deepEqual([...composeScene(scene).slice(0,3)],[255,0,0]);
pixel(top,0,0,[0,0,0,255]);assert.deepEqual([...composeScene(scene).slice(0,6)],[0,0,0,255,0,0]);
top.background="#00FF00";assert.deepEqual([...composeScene(scene).slice(3,6)],[0,255,0]);
top.pixels.fill(0);top.background=null;assert.deepEqual([...composeScene(scene).slice(0,3)],[255,0,0]);
});
test("disabled background colors stay independent through storage and copy",()=>{
const bottom=createLayer("brush"),top=createLayer("brush",[bottom]);
bottom.backgroundColor="#FF0000"; top.backgroundColor="#0000FF";
const restored=deserializeScene(serializeScene(attachScene([bottom,top])));
assert.equal(restored.layers[0].background,null);
assert.equal(restored.layers[0].backgroundColor,"#FF0000");
assert.equal(restored.layers[1].backgroundColor,"#0000FF");
assert.equal(cloneLayer(restored.layers[0],restored.layers).backgroundColor,"#FF0000");
});
test("extended pixels survive storage but never enter the central output",()=>{
const scene=blankScene(),l=scene.layers[0];pixel(l,-64,-64,[255,255,255,255]);pixel(l,127,127,[0,255,0,255]);
const rgb=composeScene(scene);assert.equal(rgb.some(Boolean),false);
const restored=deserializeScene(serializeScene(scene));assert.ok(restored);assert.equal(composeScene(restored,new Map(),192)[0],255);
assert.equal(restored.layers[0].pixels.at(-3),255);
});
test("move preview does not mutate source; confirmed clipping removes only outside ink",()=>{
const scene=blankScene(),l=scene.layers[0];l.background="#123456";pixel(l,127,0,[0,0,0,255]);pixel(l,0,0,[255,0,0,255]);
const before=serializeScene(scene);composeScene(scene,new Map(),192,{layerId:l.id,x:1,y:0});assert.equal(serializeScene(scene),before);
assert.deepEqual(translatedOverflow(l.pixels,1,0),{outside:true,inside:true});
l.pixels=clipBrushPixels(l.pixels,1,0);l.revision++;assert.equal(l.background,"#123456");
assert.deepEqual(translatedOverflow(l.pixels,1,0),{outside:false,inside:true});
const saved=deserializeScene(serializeScene(scene));assert.equal(saved.layers[0].pixels[((64)*192+191)*4+3],0);
});
test("text clipping follows anchor and scale and is retained after edit/reload/copy",()=>{
let scene=addTextElement(blankScene(),{text:"two",x:126,y:0,size:10});const element=scene.elements[0];element.clip=textClipAt(element,1,0);
const rgba=new Uint8ClampedArray(192*192*4);rgba.set([255,0,0,255],(64*192+191)*4);
assert.equal(clipTextPixels(rgba,element)[(64*192+191)*4+3],0);
const moved=updateTextElement(scene,element.id,{x:0,size:20});assert.deepEqual(moved.elements[0].clip,element.clip);
const restored=deserializeScene(serializeScene(moved));assert.deepEqual(restored.elements[0].clip,element.clip);
const copy=cloneLayer(restored.layers[1],restored.layers);assert.notEqual(copy.id,restored.layers[1].id);assert.notEqual(copy.elements[0].id,element.id);
copy.elements[0].clip[0]-=1;assert.notDeepEqual(copy.elements[0].clip,element.clip);
});
test("multiple text groups isolate edits, copies and IDs",()=>{
const a=createLayer("text"),b=createLayer("text",[a]);let scene=attachScene([createLayer("brush"),a,b]);
scene=addTextElement(scene,{text:"A"},a.id);scene=addTextElement(scene,{text:"B"},b.id);scene=addTextElement(scene,{text:"A2"},a.id);
assert.deepEqual(scene.layers[1].elements.map(e=>e.text),["A","A2"]);assert.deepEqual(scene.layers[2].elements.map(e=>e.text),["B"]);
assert.equal(new Set(scene.elements.map(e=>e.id)).size,3);
});
test("strict v2 storage protects unknown data and the final brush invariant",()=>{
const raw=JSON.parse(serializeScene(blankScene()));raw.layers=[];assert.equal(deserializeScene(raw),null);
const text=createLayer("text");assert.equal(deserializeScene({version:2,width:64,height:64,layers:[text]}),null);
const original=JSON.parse(serializeScene(blankScene()));original.layers[0].surprise=true;assert.equal(deserializeScene(original),null);
});
@@ -0,0 +1,189 @@
import base64
import copy
import json
import subprocess
import shutil
from pathlib import Path
from io import BytesIO
import pytest
from PIL import Image
from fastapi.testclient import TestClient
from app.main import create_app
from app.scene_layers import encode_pixels, decode_pixels, upgrade_scene, render_v2, render_element
from app.templates.store import TemplateStore, TemplateValidationError, prepare_scene, render_scene_image, scene_digest
from app.animations.store import AnimationStore
def legacy_scene(text=False):
elements = [dict(id="text-1", type="text", text="Hello", font="default", size=12, x=32, y=26, align="center", color="#FFFFFF")] if text else []
return dict(version=1, width=64, height=64, pixelRgb=base64.b64encode(bytes([30,60,90])*4096).decode(), elements=elements)
def brush(color=(0,0,0,0)):
return dict(id="source",name="图层1",type="brush",x=0,y=0,background=None,pixelPng=encode_pixels(Image.new("RGBA",(192,192),color)))
def test_migration_preserves_pixels_text_and_multilayer_render():
for text in (False,True):
old=legacy_scene(text);new=prepare_scene(old)
assert new["version"]==2
assert len(new["layers"])==(2 if text else 1)
assert render_scene_image(old).tobytes()==render_scene_image(new).tobytes()
layer=brush();image=decode_pixels(layer["pixelPng"]);image.putpixel((64,64),(0,0,0,255));layer["pixelPng"]=encode_pixels(image)
new=prepare_scene(legacy_scene());new["layers"].append(layer)
assert render_scene_image(new).getpixel((0,0))==(0,0,0)
assert render_scene_image(new).getpixel((1,0))==(30,60,90)
def test_disabled_background_color_is_saved_copied_and_does_not_paint(tmp_path):
layer = brush()
layer["backgroundColor"] = "#FF0000"
scene = dict(version=2,width=64,height=64,layers=[layer])
assert render_scene_image(scene).getpixel((0,0)) == (0,0,0)
store = TemplateStore(tmp_path)
saved = store.create("disabled background", scene)
copied = store.append_layer(saved["id"], layer, saved["revision"])
assert [item["backgroundColor"] for item in copied["scene"]["layers"]] == ["#FF0000"]*2
assert TemplateStore(tmp_path).get(saved["id"])["scene"] == copied["scene"]
def test_template_schema_migration_validates_old_digest_and_preserves_bad_original(tmp_path):
store=TemplateStore(tmp_path);created=store.create("legacy",legacy_scene(True))
path=tmp_path/"templates"/"records"/f"{created['id']}.json"
record=json.loads(path.read_text(encoding="utf-8"));old=legacy_scene(True);record.update(schema_version=1,scene=old,digest=scene_digest(old),thumbnail=f"{created['id']}-{scene_digest(old)}.png")
path.write_text(json.dumps(record),encoding="utf-8")
migrated=TemplateStore(tmp_path).get(created["id"])
assert migrated["scene"]["version"]==2
assert json.loads(path.read_text(encoding="utf-8"))["schema_version"]==2
record["digest"]="0000000000000000";bad=json.dumps(record).encode();path.write_bytes(bad)
with pytest.raises(TemplateValidationError):TemplateStore(tmp_path)
assert path.read_bytes()==bad
def test_animation_v2_migration_preserves_frame_ids_order_duration(tmp_path):
store=AnimationStore(tmp_path);record=store.create("old")
record=store.add_frame(record["id"],legacy_scene(),record["revision"],duration_ms=120)
root=tmp_path/"animations"/record["id"];index=root/"animation.json";raw=json.loads(index.read_text(encoding="utf-8"));raw["schema_version"]=2
old=legacy_scene();digest=scene_digest(old);frame=raw["frames"][0]
frame.update(digest=digest,scene_file=f"{frame['id']}-{digest}.json",thumbnail=f"{frame['id']}-{digest}.png")
(root/"scenes"/frame["scene_file"]).write_text(json.dumps(old),encoding="utf-8");index.write_text(json.dumps(raw),encoding="utf-8")
migrated=AnimationStore(tmp_path).get(record["id"])
assert migrated["frames"][0]["id"]==frame["id"]
assert migrated["frames"][0]["duration_ms"]==120
assert json.loads(index.read_text(encoding="utf-8"))["schema_version"]==3
@pytest.mark.parametrize("version", [True, 3.0, 4])
def test_animation_invalid_or_future_version_preserves_original(tmp_path, version):
store = AnimationStore(tmp_path)
record = store.create("protected")
path = tmp_path / "animations" / record["id"] / "animation.json"
document = json.loads(path.read_text(encoding="utf-8"))
document["schema_version"] = version
payload = json.dumps(document).encode("utf-8")
path.write_bytes(payload)
with pytest.raises(TemplateValidationError):
AnimationStore(tmp_path)
assert path.read_bytes() == payload
def test_append_layers_conflict_batch_failure_and_restart(tmp_path,monkeypatch):
app=create_app(project_root=tmp_path,driver_kind="mock");client=TestClient(app)
template=client.post("/api/templates",json=dict(name="target",scene=legacy_scene())).json();path=f"/api/templates/{template['id']}/layers"
assert client.post(path,json=dict(layer=brush())).status_code==428
copied=client.post(path,headers={"If-Match":template["revision"]},json=dict(layer=brush())).json()
assert len(copied["scene"]["layers"])==2
assert copied["scene"]["layers"][-1]["id"]!="source"
assert client.post(path,headers={"If-Match":template["revision"]},json=dict(layer=brush())).status_code==409
animations=app.state.animation_store
record=animations.create("batch")
for _ in range(3):record=animations.add_frame(record["id"],legacy_scene(),record["revision"],duration_ms=250)
ids=[f["id"] for f in record["frames"]]
root=tmp_path/"data"/"animations"/record["id"];index=root/"animation.json";before=index.read_bytes()
original=animations._write_thumbnail;count=0
def fail_second(*args):
nonlocal count
count+=1
if count==2:raise OSError("simulated full disk")
return original(*args)
monkeypatch.setattr(animations,"_write_thumbnail",fail_second)
with pytest.raises(OSError):animations.append_layer(record["id"],brush(),ids,record["revision"])
assert index.read_bytes()==before
monkeypatch.setattr(animations,"_write_thumbnail",original)
AnimationStore(tmp_path/"data")
write_index = animations._write
def fail_index(*args):
raise OSError("simulated index write failure")
monkeypatch.setattr(animations, "_write", fail_index)
with pytest.raises(OSError):
animations.append_layer(record["id"], brush(), ids, record["revision"])
assert index.read_bytes() == before
assert all(len(frame["scene"]["layers"]) == 1
for frame in AnimationStore(tmp_path/"data").get(record["id"], scenes=True)["frames"])
monkeypatch.setattr(animations, "_write", write_index)
endpoint=f"/api/animations/{record['id']}/frames/layers"
result=client.post(endpoint,headers={"If-Match":record["revision"]},json=dict(layer=brush(),frame_ids=ids[:2]))
assert result.status_code==200
restarted=AnimationStore(tmp_path/"data");detail=restarted.get(record["id"],scenes=True)
assert [len(f["scene"]["layers"]) for f in detail["frames"]]==[2,2,1]
assert [f["duration_ms"] for f in detail["frames"]]==[250]*3
assert detail["frames"][0]["scene"]["layers"][-1]["id"]!=detail["frames"][1]["scene"]["layers"][-1]["id"]
def test_config_v10_migration_only_removes_text_navigation(tmp_path):
from app.config.store import ConfigStore
store = ConfigStore(tmp_path)
path = store.path
original = json.loads(path.read_text(encoding="utf-8"))
original.update(schema_version=10, workspace_order=["templates", "text", "canvas", "animations"], brightness=27)
path.write_text(json.dumps(original), encoding="utf-8")
ConfigStore(tmp_path)
actual = json.loads(path.read_text(encoding="utf-8"))
assert actual == {**original, "schema_version": 11, "workspace_order": ["templates", "canvas", "animations"]}
def test_config_text_only_navigation_migrates_to_registered_defaults(tmp_path):
from app.config.store import ConfigStore, DEFAULT_WORKSPACE_ORDER
store=ConfigStore(tmp_path)
original=json.loads(store.path.read_text(encoding="utf-8"))
original.update(schema_version=10,workspace_order=["text"])
store.path.write_text(json.dumps(original),encoding="utf-8")
actual=ConfigStore(tmp_path)
assert actual.config["workspace_order"]==list(DEFAULT_WORKSPACE_ORDER)
def test_text_extended_preview_default_size_and_persistent_clip(tmp_path):
app=create_app(project_root=tmp_path,driver_kind="mock");client=TestClient(app)
for size in (64,192):
result=client.post("/api/preview/text-layer",json=dict(text="hello",edit_size=size))
assert result.status_code==200
assert Image.open(BytesIO(result.content)).size==(size,size)
element=upgrade_scene(legacy_scene(True))["layers"][1]["elements"][0]
element["clip"]=[-1,0,0,2]
image=render_element(element)
assert image.getpixel((element["x"]+64,element["y"]+64))[3]==0
scene=prepare_scene(legacy_scene());scene["layers"].append(dict(id="text-layer",name="图层2",type="text",x=0,y=0,elements=[element]))
store=TemplateStore(tmp_path/"clip");saved=store.create("clipped",scene)
assert TemplateStore(tmp_path/"clip").get(saved["id"])["scene"]==scene
def test_browser_python_composition_and_png_are_identical(tmp_path):
node=shutil.which("node")
if not node:pytest.skip("Node is only required for local cross-language parity")
scene=prepare_scene(legacy_scene(True));top=brush();top.update(x=-10,y=10,background="#123456")
image=decode_pixels(top["pixelPng"])
for y in range(64):
for x in range(64):
image.putpixel((64+x,64+y), ((x*19)%256,(y*31)%256,(x+y)*2,(x*7+y*13)%256))
top["pixelPng"]=encode_pixels(image)
scene["layers"].insert(1,top);scene["layers"][2]["x"]=5
element=scene["layers"][2]["elements"][0];element["clip"]=[-1,0,1,2]
overlapping = copy.deepcopy(element)
overlapping.update(id="overlapping",color="#22BBFF",x=30,clip=None)
scene["layers"][2]["elements"].append(overlapping)
text = {e["id"]:base64.b64encode(render_element(e).tobytes()).decode() for e in (element,overlapping)}
fixture=tmp_path/"parity.json";fixture.write_text(json.dumps(dict(scene=scene,text=text)),encoding="utf-8")
module=(Path(__file__).parents[1]/"app/static/scene-model.js").as_uri()
code='import fs from "node:fs";import {deserializeScene,composeScene} from '+json.dumps(module)+';const f=JSON.parse(fs.readFileSync(process.argv[1],"utf8"));const m=new Map(Object.entries(f.text).map(([k,v])=>[k,new Uint8ClampedArray(Buffer.from(v,"base64"))]));console.log(Buffer.from(composeScene(deserializeScene(f.scene),m)).toString("base64"));'
result=subprocess.run([node,"--input-type=module","-e",code,str(fixture)],capture_output=True,text=True,encoding="utf-8",check=True)
assert base64.b64decode(result.stdout.strip())==render_v2(scene).tobytes()
@@ -1,3 +1,4 @@
import * as v1 from "../app/static/scene-model-v1.js";
import test from "node:test";
import assert from "node:assert/strict";
import {
@@ -78,23 +79,23 @@ test("serializes pixelRgb as base64 and round trips an independent scene", () =>
color: "#12abef",
});
const stored = sceneToStorageValue(original);
assert.equal(typeof stored.pixelRgb, "string");
assert.equal(typeof stored.layers[0].pixelPng, "string");
assert.equal("pixels" in stored, false);
assert.equal("pixelsRgb" in stored, false);
assert.equal("revision" in stored.elements[0], false);
assert.equal("revision" in stored.layers[1].elements[0], false);
const restored = deserializeScene(serializeScene(original));
assert.ok(restored);
assert.deepEqual(Array.from(restored.pixels), Array.from(original.pixels));
assert.deepEqual(restored.elements, original.elements);
assert.notStrictEqual(restored.pixels, original.pixels);
assert.notStrictEqual(restored.elements[0], original.elements[0]);
assert.equal(restored.elements[0].text, "你好 · مرحبا · नमस्ते · ไทย");
assert.notStrictEqual(restored.layers[1].elements[0], original.elements[0]);
assert.equal(restored.layers[1].elements[0].text, "你好 · مرحبا · नमस्ते · ไทย");
});
test("moves a valid v1 draft to the stable key before removing exact legacy keys", () => {
const original = addTextElement(createScene({ pixels: patternedPixels(23) }), { text: "keep me" });
const storage = memoryStorage({
[LEGACY_STORAGE_KEYS.sceneV1]: serializeScene(original),
[LEGACY_STORAGE_KEYS.sceneV1]: v1.serializeScene(v1.createScene({pixels:original.pixels,elements:original.elements})),
[LEGACY_STORAGE_KEYS.canvasV3]: JSON.stringify({ pixels: Array.from(patternedPixels(2)) }),
[LEGACY_STORAGE_KEYS.textV1]: JSON.stringify({ "text-value": "older text" }),
"unrelated:key": "keep",
@@ -117,7 +118,7 @@ test("moves a valid v1 draft to the stable key before removing exact legacy keys
});
test("keeps the v1 draft and all legacy keys when stable-key migration cannot write", () => {
const originalRaw = serializeScene(addTextElement(createScene(), { text: "do not delete" }));
const originalRaw = v1.serializeScene(v1.addTextElement(v1.createScene(), { text: "do not delete" }));
const storage = memoryStorage({
[LEGACY_STORAGE_KEYS.sceneV1]: originalRaw,
[LEGACY_STORAGE_KEYS.canvasV2]: JSON.stringify({ pixels: Array.from(patternedPixels(4)) }),
@@ -184,23 +185,14 @@ test("does not discard a damaged higher-priority legacy key during fallback migr
test("rejects damaged scene roots while skipping only damaged elements", () => {
const valid = sceneToStorageValue(addTextElement(createScene(), { text: "keep" }));
assert.equal(deserializeScene({ ...valid, version: 2 }), null);
assert.equal(deserializeScene({ ...valid, version: 3 }), null);
assert.equal(deserializeScene({ ...valid, width: 32 }), null);
assert.equal(deserializeScene({ ...valid, pixelRgb: "not base64" }), null);
assert.equal(deserializeScene("{broken"), null);
const recovered = deserializeScene({
...valid,
elements: [
valid.elements[0],
{ id: "bad-type", type: "image", text: "skip" },
{ id: "bad-text", type: "text", text: 123 },
{ ...valid.elements[0], text: "duplicate id" },
],
});
assert.ok(recovered);
assert.equal(recovered.elements.length, 1);
assert.equal(recovered.elements[0].text, "keep");
const invalid = {...valid,layers:valid.layers.map((l,i)=>i===1?{...l,elements:[...l.elements,{...l.elements[0]}]}:l)};
assert.equal(deserializeScene(invalid), null, "duplicate ids must not be silently dropped");
});
test("normalizes text fields and creates a centered lowercase ok element", () => {
@@ -235,6 +227,7 @@ test("normalizes text fields and creates a centered lowercase ok element", () =>
align: DEFAULT_TEXT_ELEMENT.align,
color: "#12ABEF",
revision: 0,
clip: null,
});
assert.equal("background" in normalized, false);
});
@@ -270,7 +270,7 @@ def test_template_preserves_multilingual_text(tmp_path):
assert created.status_code == 201
stored = client.get(f"/api/templates/{created.json()['id']}")
assert stored.status_code == 200
assert stored.json()["scene"]["elements"][0]["text"] == multilingual
assert stored.json()["scene"]["layers"][1]["elements"][0]["text"] == multilingual
def test_template_mutations_require_current_revision(tmp_path):
@@ -294,7 +294,7 @@ def test_template_mutations_require_current_revision(tmp_path):
assert client.delete(path, headers=stale_headers).status_code == 409
preserved = client.get(path).json()
assert preserved["name"] == "shared"
assert preserved["scene"]["elements"][0]["text"] == "first"
assert preserved["scene"]["layers"][1]["elements"][0]["text"] == "first"
assert preserved["revision"] == current["revision"]
@@ -467,7 +467,7 @@ def test_template_migration_write_failure_rolls_back_every_record(tmp_path, monk
[
(("scene", "version"), True),
(("scene", "width"), 64.0),
(("scene", "elements", 0, "size"), True),
(("scene", "layers", 1, "elements", 0, "size"), True),
],
)
def test_current_template_schema_rejects_noncanonical_json_types(
@@ -492,7 +492,7 @@ def test_current_template_schema_rejects_noncanonical_json_types(
original = (json.dumps(document, separators=(",", ":")) + "\n").encode("utf-8")
record_path.write_bytes(original)
with pytest.raises(TemplateValidationError, match="must be an integer"):
with pytest.raises(TemplateValidationError, match="integer|fields or dimensions"):
TemplateStore(data_dir)
assert record_path.read_bytes() == original
@@ -70,17 +70,18 @@ def test_generated_ui_copy_catalog_is_current_and_covers_static_and_dynamic_copy
"copy.ff765a69c8b49cee": "全白(高亮度会导致严重耗电)",
}
finalized_hidden = {
"copy.0a2bacf6cf154355", "copy.18abf73b22c104c1", "copy.209c8a51007fadfb",
"copy.4a2ff24bc62e26ef", "copy.55db93bad76b7e74", "copy.56f1193ee4a7192d",
"copy.5e26a038197f59b8", "copy.62029c969ffb9c9d", "copy.670c95eddb9bac43",
"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", "copy.efdd13bfd5037544",
"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)
assert len(finalized_text) + len(finalized_hidden) == 34
# 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):