Files

78 lines
5.8 KiB
JavaScript

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