import * as v1 from "../app/static/scene-model-v1.js"; import test from "node:test"; import assert from "node:assert/strict"; import { DEFAULT_TEXT_ELEMENT, LEGACY_STORAGE_KEYS, RGB_BYTE_LENGTH, RGBA_BYTE_LENGTH, SCENE_HEIGHT, SCENE_STORAGE_KEY, SCENE_VERSION, SCENE_WIDTH, addTextElement, compositeRgbaLayer, compositeRgbaLayers, createRgbPixels, createScene, createTextElement, deleteTextElement, deserializeScene, duplicateTextElement, inspectSceneStorageValue, migrateLegacyScene, normalizeTextElement, restoreSceneDraft, sceneToStorageValue, serializeScene, updateTextElement, } from "../app/static/scene-model.js"; function patternedPixels(seed = 0) { return Uint8ClampedArray.from({ length: RGB_BYTE_LENGTH }, (_, index) => (index + seed) % 256); } function rgbaPixel(red, green, blue, alpha) { const layer = new Uint8ClampedArray(RGBA_BYTE_LENGTH); layer.set([red, green, blue, alpha], 0); return layer; } function memoryStorage(initial = {}, { failSet = false } = {}) { const values = new Map(Object.entries(initial)); const operations = []; return { values, operations, getItem(key) { operations.push(["get", key]); return values.has(key) ? values.get(key) : null; }, setItem(key, value) { operations.push(["set", key]); if (failSet) throw new Error("quota exceeded"); values.set(key, String(value)); }, removeItem(key) { operations.push(["remove", key]); values.delete(key); }, }; } test("creates a fixed 64x64 RGB scene", () => { const pixels = createRgbPixels([12, 34, 56]); const scene = createScene({ pixels }); assert.equal(SCENE_STORAGE_KEY, "matrixController:scene"); assert.equal(scene.version, SCENE_VERSION); assert.equal(scene.width, SCENE_WIDTH); assert.equal(scene.height, SCENE_HEIGHT); assert.equal(scene.pixels.length, RGB_BYTE_LENGTH); assert.deepEqual(Array.from(scene.pixels.slice(0, 6)), [12, 34, 56, 12, 34, 56]); pixels[0] = 255; assert.equal(scene.pixels[0], 12, "scene owns a copy of its pixel layer"); }); test("serializes pixelRgb as base64 and round trips an independent scene", () => { const original = addTextElement(createScene({ pixels: patternedPixels(7) }), { text: "你好 · مرحبا · नमस्ते · ไทย", color: "#12abef", }); const stored = sceneToStorageValue(original); 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.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.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]: 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", }); const loaded = restoreSceneDraft(storage); assert.equal(loaded.status, "migrated"); assert.equal(loaded.canPersist, true); assert.deepEqual(Array.from(loaded.scene.pixels), Array.from(original.pixels)); assert.equal(loaded.scene.elements[0].text, "keep me"); assert.ok(deserializeScene(storage.values.get(SCENE_STORAGE_KEY))); for (const key of Object.values(LEGACY_STORAGE_KEYS)) { assert.equal(storage.values.has(key), false); } assert.equal(storage.values.get("unrelated:key"), "keep"); const setIndex = storage.operations.findIndex(([operation]) => operation === "set"); const firstRemoveIndex = storage.operations.findIndex(([operation]) => operation === "remove"); assert.ok(setIndex >= 0 && firstRemoveIndex > setIndex, "the new draft is written before legacy cleanup"); }); test("keeps the v1 draft and all legacy keys when stable-key migration cannot write", () => { 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)) }), }, { failSet: true }); const loaded = restoreSceneDraft(storage); assert.equal(loaded.status, "write-failed"); assert.equal(loaded.canPersist, false); assert.equal(storage.values.has(SCENE_STORAGE_KEY), false); assert.equal(storage.values.get(LEGACY_STORAGE_KEYS.sceneV1), originalRaw); assert.equal(storage.values.has(LEGACY_STORAGE_KEYS.canvasV2), true); assert.equal(storage.operations.some(([operation]) => operation === "remove"), false); }); test("preserves and blocks a future stable draft instead of falling back or writing blank", () => { const futureRaw = JSON.stringify({ ...sceneToStorageValue(createScene({ pixels: patternedPixels(31) })), version: SCENE_VERSION + 1, }); const legacyRaw = serializeScene(addTextElement(createScene(), { text: "older fallback" })); const storage = memoryStorage({ [SCENE_STORAGE_KEY]: futureRaw, [LEGACY_STORAGE_KEYS.sceneV1]: legacyRaw, }); assert.equal(inspectSceneStorageValue(futureRaw).status, "future"); const loaded = restoreSceneDraft(storage); assert.equal(loaded.status, "future-version"); assert.equal(loaded.canPersist, false); assert.equal(storage.values.get(SCENE_STORAGE_KEY), futureRaw); assert.equal(storage.values.get(LEGACY_STORAGE_KEYS.sceneV1), legacyRaw); assert.equal(storage.operations.some(([operation]) => operation === "set"), false); assert.equal(storage.operations.some(([operation]) => operation === "remove"), false); }); test("preserves and blocks a damaged stable draft", () => { const storage = memoryStorage({ [SCENE_STORAGE_KEY]: "{damaged" }); const loaded = restoreSceneDraft(storage); assert.equal(loaded.status, "damaged-current"); assert.equal(loaded.canPersist, false); assert.equal(storage.values.get(SCENE_STORAGE_KEY), "{damaged"); assert.equal(storage.operations.some(([operation]) => operation === "set"), false); }); test("does not discard a damaged higher-priority legacy key during fallback migration", () => { const validFallback = JSON.stringify({ pixels: Array.from(patternedPixels(6)) }); const storage = memoryStorage({ [LEGACY_STORAGE_KEYS.canvasV3]: "{damaged", [LEGACY_STORAGE_KEYS.canvasV2]: validFallback, }); const loaded = restoreSceneDraft(storage); assert.equal(loaded.status, "legacy-migration-failed"); assert.equal(loaded.canPersist, false); assert.equal(storage.values.has(SCENE_STORAGE_KEY), false); assert.equal(storage.values.get(LEGACY_STORAGE_KEYS.canvasV3), "{damaged"); assert.equal(storage.values.get(LEGACY_STORAGE_KEYS.canvasV2), validFallback); assert.equal(storage.operations.some(([operation]) => operation === "remove"), false); }); test("rejects damaged scene roots while skipping only damaged elements", () => { const valid = sceneToStorageValue(addTextElement(createScene(), { text: "keep" })); 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 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", () => { const defaults = createTextElement([]); assert.equal(defaults.text, "ok"); assert.equal(defaults.color, "#FFFFFF"); assert.equal(defaults.align, "center"); assert.equal(defaults.x, 32); assert.equal(defaults.y, 26); const normalized = normalizeTextElement({ id: " custom ", type: "text", text: "hello", font: " custom.ttf ", size: 100, x: -500, y: 11.7, align: "invalid", color: "#12abef", revision: -3, background: "#FF0000", }); assert.deepEqual(normalized, { id: "custom", type: "text", text: "hello", font: "custom.ttf", size: 64, x: -128, y: 12, align: DEFAULT_TEXT_ELEMENT.align, color: "#12ABEF", revision: 0, clip: null, }); assert.equal("background" in normalized, false); }); test("migrates the first valid canvas by v3, v2, legacy priority and merges text v1", () => { const v3 = patternedPixels(3); const v2 = patternedPixels(9); const legacy = patternedPixels(17); const textV1 = { "text-value": "legacy text", "text-font": "legacy.ttf", "text-size": "18", "text-x": "7", "text-y": "8", "text-align": "right", "text-color": "#abcdef", "text-background": "#123456", }; const migrated = migrateLegacyScene({ canvasV3: JSON.stringify({ pixels: Array.from(v3) }), canvasV2: { pixels: Array.from(v2) }, matrixCanvasRgb: Array.from(legacy).join(","), textV1: JSON.stringify(textV1), }); assert.ok(migrated); assert.deepEqual(Array.from(migrated.pixels), Array.from(v3)); assert.equal(migrated.elements[0].text, "legacy text"); assert.equal(migrated.elements[0].font, "legacy.ttf"); assert.equal(migrated.elements[0].size, 18); assert.equal(migrated.elements[0].color, "#ABCDEF"); assert.equal("background" in migrated.elements[0], false); const fallback = migrateLegacyScene({ [LEGACY_STORAGE_KEYS.canvasV3]: "{damaged", [LEGACY_STORAGE_KEYS.canvasV2]: JSON.stringify({ pixels: Array.from(v2) }), [LEGACY_STORAGE_KEYS.canvasRgb]: Array.from(legacy).join(","), }); assert.deepEqual(Array.from(fallback.pixels), Array.from(v2)); const oldest = migrateLegacyScene({ canvasV3: { pixels: [1, 2, 3] }, canvasV2: null, matrixCanvasRgb: Array.from(legacy).join(","), }); assert.deepEqual(Array.from(oldest.pixels), Array.from(legacy)); assert.equal(migrateLegacyScene({ canvasV3: "bad", textV1: "bad" }), null); }); test("supports independent add, update, and delete operations for multiple text elements", () => { let scene = addTextElement(createScene(), { text: "one", x: 1, color: "#FF0000" }); scene = addTextElement(scene, { text: "two", x: 20, color: "#00FF00" }); const [first, second] = scene.elements; assert.notEqual(first.id, second.id); const updated = updateTextElement(scene, first.id, { text: "ONE", x: 6, color: "#0000ff" }); assert.equal(updated.elements[0].text, "ONE"); assert.equal(updated.elements[0].x, 6); assert.equal(updated.elements[0].color, "#0000FF"); assert.equal(updated.elements[0].revision, first.revision + 1); assert.deepEqual(updated.elements[1], second, "editing one element leaves the other unchanged"); assert.equal(scene.elements[0].text, "one", "CRUD helpers do not mutate the input scene"); const deleted = deleteTextElement(updated, first.id); assert.deepEqual(deleted.elements.map((element) => element.id), [second.id]); }); test("duplicates with a deep copy, new id, 2px offset, and topmost order", () => { let scene = addTextElement(createScene(), { text: "copy me", x: 126, y: 10 }); const original = scene.elements[0]; scene = duplicateTextElement(scene, original.id); const duplicate = scene.elements[1]; assert.equal(scene.elements.length, 2); assert.notStrictEqual(duplicate, original); assert.notEqual(duplicate.id, original.id); assert.equal(duplicate.text, original.text); assert.equal(duplicate.x, 128); assert.equal(duplicate.y, 12); assert.equal(duplicate.revision, 0); assert.equal(scene.elements.at(-1).id, duplicate.id, "the duplicate is appended as the top layer"); const changed = updateTextElement(scene, duplicate.id, { text: "changed" }); assert.equal(changed.elements[0].text, "copy me"); assert.equal(changed.elements[1].text, "changed"); }); test("alpha-composites full RGBA layers in array order without mutating inputs", () => { const base = createRgbPixels([0, 0, 0]); const red = rgbaPixel(255, 0, 0, 128); const blue = rgbaPixel(0, 0, 255, 128); const result = compositeRgbaLayers(base, [red, blue]); assert.deepEqual(Array.from(result.slice(0, 3)), [64, 0, 128]); assert.deepEqual(Array.from(base.slice(0, 3)), [0, 0, 0]); assert.deepEqual(Array.from(compositeRgbaLayers(base, [blue, red]).slice(0, 3)), [128, 0, 64]); assert.deepEqual(Array.from(compositeRgbaLayer(base, rgbaPixel(10, 20, 30, 255)).slice(0, 3)), [10, 20, 30]); assert.throws(() => compositeRgbaLayers(base, [new Uint8Array(4)]), /64x64 RGBA8888/); });