初始化奇妙小屏幕控制器项目
This commit is contained in:
@@ -0,0 +1,333 @@
|
||||
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/);
|
||||
});
|
||||
Reference in New Issue
Block a user