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

190 lines
11 KiB
Python

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()