Files
matrix-screen-controller/核桃派软件源代码/tests/test_scene_model.mjs
T

334 lines
13 KiB
JavaScript

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.pixelRgb, "string");
assert.equal("pixels" in stored, false);
assert.equal("pixelsRgb" in stored, false);
assert.equal("revision" in stored.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, "你好 · مرحبا · नमस्ते · ไทย");
});
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.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 = serializeScene(addTextElement(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: 2 }), 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");
});
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,
});
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/);
});