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