Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
07e931b46This file is pinned to the selected release. Download the original to open it in your design tool.
/**
* Deck pack smoke test — `npm run check:pack`.
*
* The pack is a deck's shareable form: a .zip of .pfm + .json + catalog.txt
* served from a stable URL, assembled per request from the module cache
* (lib/community/server/deckZip.ts + modulePack.ts). The database side — which
* slots go in, which are skipped and why, 202 while baking — is driven through
* the real route in check:modcache. This file pins the pure parts, each of
* which is quiet when it breaks.
*
* The CATALOG is the running order the device reads. Written wrong — extra
* blanks, missing trailing newline, wrong order — and the device falls back
* to sorting alphabetically, silently discarding the arrangement.
*
* FILE NAMES are what the board keys a module by. Two slots landing on one
* name used to fail the whole pack; now the later one is renamed, and the
* rename has to survive the board's 39-character cut or it collides again
* on arrival.
*
* The SIDECAR is what a board lists before it loads a module. The device
* reads the first "name" token it finds with a substring scan, so "name"
* must come first and be spelled the way Python's json.dumps spells it.
*
* The PERFORMANCE decoration must add the table, leave the modules alone,
* and refuse to fail loudly: a broken attachment may cost its own file and
* nothing else.
*/
import { unzipSync, zipSync } from "fflate";
import {
decoratePackWithPerformance,
deckZipFilename,
performanceEntry,
} from "../src/lib/community/server/deckZip";
import {
DEVICE_SLUG_MAX,
assignPackSlugs,
catalogText,
composeSidecar,
pythonJsonDumps,
sanitizeSidecar,
zipFiles,
} from "../src/lib/community/server/modulePack";
import { decodePfst } from "../src/lib/pattern/pfst";
let failures = 0;
function check(label: string, actual: unknown, expected: unknown) {
const ok = JSON.stringify(actual) === JSON.stringify(expected);
if (!ok) failures += 1;
console.log(
`${ok ? " ok " : "FAIL "}${label}` +
(ok ? "" : `\n got ${JSON.stringify(actual)}\n want ${JSON.stringify(expected)}`),
);
}
console.log("\ncatalog.txt — the running order the device reads\n");
// The firmware's applyCatalogOrder() skips blank lines and lines starting with
// '#', matches the rest against module filenames, and leaves anything
// unlisted in its existing order.
const catalog = catalogText(["cell_ripple", "conway_life", "wave_saw"]);
const lines = catalog.split("\n");
const meaningful = lines.filter((line) => line.trim() && !line.startsWith("#"));
check("slugs come out in deck order", meaningful, ["cell_ripple", "conway_life", "wave_saw"]);
check("ends with a newline", catalog.endsWith("\n"), true);
check("no blank line before the first slug", lines[2], "cell_ripple");
check("comments are '#'-prefixed", lines.slice(0, 2).every((l) => l.startsWith("#")), true);
// One module is the common case for a single-pattern deck and the one most
// likely to produce a stray empty line.
check(
"a one-module pack is well formed",
catalogText(["origin"]).split("\n").filter((l) => l.trim() && !l.startsWith("#")),
["origin"],
);
console.log("\nfile names — unique on the board, in slot order\n");
check("distinct names are left alone", assignPackSlugs(["a", "b", "c"]), ["a", "b", "c"]);
check(
"a repeated name is renamed, not dropped",
assignPackSlugs(["wave", "wave", "wave"]),
["wave", "wave_2", "wave_3"],
);
// port_preset.py names every non-ASCII NAME "pattern" — a deck of Korean
// titles is exactly this.
check(
"the non-ASCII fallback name spreads out",
assignPackSlugs(["pattern", "pattern", "origin", "pattern"]),
["pattern", "pattern_2", "origin", "pattern_3"],
);
// A real slot that is already called wave_2 must not be overwritten by a
// renamed duplicate.
check(
"a rename skips names another slot already has",
assignPackSlugs(["wave", "wave_2", "wave"]),
["wave", "wave_2", "wave_3"],
);
check("FAT is case-insensitive, so the check is too", assignPackSlugs(["Wave", "wave"]), ["Wave", "wave_2"]);
const long = "a".repeat(50);
const longNames = assignPackSlugs([long, long]);
check("a long name is cut to what the board keeps", longNames[0].length, DEVICE_SLUG_MAX);
check("its duplicate still fits after the suffix", longNames[1].length, DEVICE_SLUG_MAX);
check("…and still differs after the cut", longNames[0] === longNames[1], false);
check("the suffix survives", longNames[1].endsWith("_2"), true);
// Two names that differ only past character 39 are one file on the board.
check(
"names equal within the board's 39 characters collide, and are separated",
assignPackSlugs([`${"b".repeat(39)}x`, `${"b".repeat(39)}y`]).map((s) => s.length <= DEVICE_SLUG_MAX),
[true, true],
);
check(
"…into two different files",
new Set(assignPackSlugs([`${"b".repeat(39)}x`, `${"b".repeat(39)}y`])).size,
2,
);
console.log("\nsidecar — what a board lists before it loads the module\n");
// What build_module.py writes, including the two worker-local fields.
const raw = pythonJsonDumps({
name: "Café Ripple",
namespace: "CafeRipple",
author: "unknown",
license: "CC-BY-SA-4.0",
abi: 2,
knobs: ["Speed", "Hue", "Scale", "Fade"],
source: "/home/pi/Patternflow/.build-worker/modules/0123456789abcdef-0/src/pattern1.h",
slug: "caf_ripple",
absoluteReady: true,
panel_w: 128,
panel_h: 64,
module: "caf_ripple.pfm",
size: 6120,
opt: "-Os",
});
check("non-ASCII is written as \\uXXXX, like Python", raw.includes('"Caf\\u00e9 Ripple"'), true);
check("indent is two spaces, like Python", raw.split("\n")[1], ' "name": "Caf\\u00e9 Ripple",');
check(
"an astral character becomes a surrogate pair, like Python",
pythonJsonDumps({ name: "🌊" }),
'{\n "name": "\\ud83c\\udf0a"\n}',
);
check("DEL is escaped too, like Python", pythonJsonDumps("\u007f"), '"\\u007f"');
const stored = sanitizeSidecar(raw);
check("the cache drops the worker's temp path", stored.includes(".build-worker"), false);
check("…and the optimisation flag", JSON.parse(stored).opt, undefined);
check("…and keeps what the device reads", JSON.parse(stored).absoluteReady, true);
check("a broken sidecar sanitises to an empty object", sanitizeSidecar("{nope"), "{}\n");
const source = {
id: "p123",
license: "CC-BY-4.0",
authorHandle: "Alice",
header: { code: "#pragma once", source: "port" as const, ownerId: "u2", portId: "h1", porterHandle: "bob" },
};
const served = composeSidecar(stored, { name: "Café Ripple", slug: "caf_ripple_2", source });
const servedJson = JSON.parse(served);
check("\"name\" is the very first key", served.split("\n")[1].startsWith(' "name"'), true);
check(
"the first \"name\" token is the name (the device's substring scan)",
served.slice(served.indexOf('"name"')).split('"')[3],
"Caf\\u00e9 Ripple",
);
check("the author comes from the database, porter credited", servedJson.author, "Alice (firmware port by bob)");
check("the licence comes from the pattern row", servedJson.license, "CC-BY-4.0");
check("it links back to the pattern", servedJson.url, "https://patternflow.work/community/p/p123");
check("the slug is the file it will actually be", [servedJson.slug, servedJson.module], ["caf_ripple_2", "caf_ripple_2.pfm"]);
check("absoluteReady survives", servedJson.absoluteReady, true);
check("no worker path reaches a board", served.includes("source"), false);
check(
"an author header credits the author alone",
JSON.parse(
composeSidecar(stored, {
name: null,
slug: "x",
source: { ...source, header: { code: "", source: "author" as const, ownerId: "u1" } },
}),
).author,
"Alice",
);
check(
"a missing cached sidecar still yields a named one",
JSON.parse(composeSidecar(null, { name: "Fallback", slug: "fallback", source })).name,
"Fallback",
);
console.log("\nfilename — says what it is\n");
check("an ASCII title slugs", deckZipFilename("My Sunset Set!", "0123456789abcdef"), "patternflow-deck-my-sunset-set.zip");
check(
"a title with no ASCII falls back to the id",
deckZipFilename("노을 세트", "0123456789abcdef"),
"patternflow-deck-01234567.zip",
);
check(
"a long title never ends in a dash",
deckZipFilename(`${"ab ".repeat(30)}`, "0123456789abcdef").endsWith("-.zip"),
false,
);
console.log("\nthe archive is deterministic\n");
const files = {
"wave_saw.pfm": new Uint8Array([0x7f, 0x45, 0x4c, 0x46, 1, 2, 3, 4]),
"wave_saw.json": new TextEncoder().encode('{"name":"Wave Saw"}\n'),
"catalog.txt": new TextEncoder().encode(catalogText(["wave_saw"])),
};
const first = zipFiles(files);
check("same inputs, same bytes", Buffer.from(first).equals(Buffer.from(zipFiles({ ...files }))), true);
check("and it unpacks to what went in", Object.keys(unzipSync(first)).sort(), ["catalog.txt", "wave_saw.json", "wave_saw.pfm"]);
// ── performance decoration ───────────────────────────────────────────────
console.log("\nperformance rides the pack");
const basePack = zipSync({
"wave_saw.pfm": new Uint8Array([0x7f, 0x45, 0x4c, 0x46, 1, 2, 3, 4]),
"wave_saw.json": new TextEncoder().encode('{"slug":"wave_saw","abi":1}'),
"catalog.txt": new TextEncoder().encode("wave_saw\n"),
});
const performanceJson = JSON.stringify({
version: 1,
id: "sunset-set",
title: "Sunset Set",
length: 24,
loop: true,
timeline: [
{ t: 0, pattern: "Wave Saw" },
{ t: 0, param: [200, 500, 500, 800] },
{ t: 12, param: [800, 500, 200, 100] },
{ t: 20, message: "encore" },
],
});
const decorated = unzipSync(decoratePackWithPerformance(basePack, performanceJson));
const names = Object.keys(decorated).sort();
check("the modules survive untouched", names.includes("wave_saw.pfm"), true);
check("so does the running order", names.includes("catalog.txt"), true);
check("the packed table is added, named from the id", names.includes("sunset_set.pfs"), true);
// The Director opens and saves .pfs, so the table IS the document — a JSON
// beside it would be a file nothing opens.
check("no JSON rides along", names.includes("performance.json"), false);
check("the route's entry and the decoration agree on the name", performanceEntry(performanceJson)?.name, "sunset_set.pfs");
// The .pfs is what the panel plays, so it has to be a table, not bytes that
// merely exist — decode it back and check the timeline survived the trip.
const table = decodePfst(decorated["sunset_set.pfs"]);
check("the .pfs decodes to the same show", table.title, "Sunset Set");
check("its cues are all there", table.timeline.length, 4);
check("its pattern cue points at the pattern", table.timeline[0].pattern, "Wave Saw");
check("loop survives", table.loop, true);
check("a sparse param patch survives", table.timeline[2].param, [800, 500, 200, 100]);
// A deck with no performance must get byte-identical bytes back, or every
// download of every plain deck pays for a pointless repack.
check(
"no performance means the pack is not touched at all",
decoratePackWithPerformance(basePack, null) === basePack,
true,
);
// Broken attachments are the case that must not cascade: the pack is what
// people came for.
for (const [label, bad] of [
["not JSON", "{{{"],
["no timeline", '{"version":1,"title":"Empty"}'],
] as const) {
const out = decoratePackWithPerformance(basePack, bad);
const entries = Object.keys(unzipSync(out));
check(`a performance that is ${label} still leaves an installable pack`, entries.includes("wave_saw.pfm"), true);
}
// Unreadable JSON contributes nothing at all. (A readable one with no
// timeline normalises to an empty show, as it always has.)
check("an unreadable performance contributes no entry", performanceEntry("{{{"), null);
console.log(failures ? `\n${failures} FAILED\n` : "\nall passed\n");
process.exit(failures ? 1 : 0);