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