Improve Android app UI and controller flows
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user