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); });