Files
Epicnabbo-Catalogus-Updated…/src/lib/furni/nitro-scale32.test.ts
T
openhands 4d720ee03c fix(furni): generate scale 32 from bundles with .png frame keys
resolveNitroFrame now matches spritesheet frame keys that carry a .png
suffix or namespaced naming, and isScale/scaleName preserve that suffix.
Broken source sprites (missing frames or references to icon artwork) are
skipped and reported instead of aborting the whole generation, and the
studio UI surfaces the skipped count.
2026-09-12 12:46:17 +02:00

265 lines
7.2 KiB
TypeScript

import sharp from "sharp";
import { describe, expect, it } from "vitest";
import {
createNitroBundle,
parseNitroBundle,
} from "@/lib/services/swf/nitro-builder";
import { generateScale32 } from "./nitro-scale32";
const metadata = () => ({
name: "chair",
assets: {
chair_64_a_0_0: { x: -5, y: 7 },
chair_64_a_2_0: { source: "chair_64_a_0_0", x: 5, y: 7, flipH: true },
},
visualizations: [
{
size: 64,
layerCount: 1,
layers: { 0: { x: 6, y: -4, z: 3 } },
animations: {
0: {
layers: {
0: {
frameRepeat: 4,
frameSequences: {
0: { frames: { 0: { id: 0, x: 6, y: 4, randomX: 8 } } },
},
},
},
},
},
},
],
logic: { model: { dimensions: { x: 2, y: 1, z: 1 } } },
spritesheet: {
frames: {
chair_64_a_0_0: {
frame: { x: 0, y: 0, w: 6, h: 8 },
rotated: false,
trimmed: false,
spriteSourceSize: { x: 0, y: 0, w: 6, h: 8 },
sourceSize: { w: 6, h: 8 },
},
},
meta: { size: { w: 6, h: 8 }, scale: "1" },
},
});
async function bundle(json = metadata()) {
return createNitroBundle(
json,
await sharp({
create: { width: 6, height: 8, channels: 4, background: "#ff0000" },
})
.png()
.toBuffer(),
"chair",
);
}
describe("scale 32 generation", () => {
it("preserves originals and adds half-size artwork and offsets without changing timing or geometry", async () => {
const source = await bundle();
const result = await generateScale32(source);
const { json, png } = parseNitroBundle(result.bundle);
const m = json as ReturnType<typeof metadata>;
expect(m.assets.chair_64_a_0_0).toEqual(metadata().assets.chair_64_a_0_0);
expect(m.logic).toEqual(metadata().logic);
expect(
(json.assets as Record<string, unknown>).chair_32_a_0_0,
).toMatchObject({ x: -2, y: 4 });
expect(
(json.assets as Record<string, unknown>).chair_32_a_2_0,
).toMatchObject({
source: "chair_32_a_0_0",
flipH: true,
});
expect(
(json.spritesheet as { frames: Record<string, { frame: unknown }> })
.frames.chair_32_a_0_0.frame,
).toMatchObject({ w: 3, h: 4 });
expect(m.visualizations[1]).toMatchObject({
size: 32,
layers: { 0: { x: 3, y: -2, z: 3 } },
animations: {
0: {
layers: {
0: {
frameRepeat: 4,
frameSequences: {
0: { frames: { 0: { id: 0, x: 3, y: 2, randomX: 4 } } },
},
},
},
},
},
});
expect(
await sharp(png)
.extract({ left: 0, top: 0, width: 6, height: 8 })
.raw()
.toBuffer(),
).toEqual(await sharp(parseNitroBundle(source).png).raw().toBuffer());
});
it("never overwrites a native or partial scale32", async () => {
const m = metadata();
m.visualizations.push({ ...m.visualizations[0], size: 32 });
await expect(generateScale32(await bundle(m))).rejects.toThrow("already");
});
it("skips sprites whose artwork is missing and reports them", async () => {
const m = metadata();
m.assets.chair_64_a_2_0.source = "missing";
const result = await generateScale32(await bundle(m));
expect(result.skipped).toBe(1);
const { json } = parseNitroBundle(result.bundle);
expect(
(json.assets as Record<string, unknown>).chair_32_a_0_0,
).toBeDefined();
expect(
(json.assets as Record<string, unknown>).chair_32_a_2_0,
).toBeUndefined();
});
});
it("restores trimmed sprite geometry before reducing it", async () => {
const png = await sharp({
create: { width: 2, height: 2, channels: 4, background: "#00ff00" },
})
.png()
.toBuffer();
const source = createNitroBundle(
{
name: "trim",
assets: { trim_64_a_0_0: { x: 4, y: 6 } },
visualizations: [{ size: 64 }],
spritesheet: {
frames: {
trim_64_a_0_0: {
frame: { x: 0, y: 0, w: 2, h: 2 },
trimmed: true,
sourceSize: { w: 6, h: 8 },
spriteSourceSize: { x: 2, y: 4, w: 2, h: 2 },
},
},
},
},
png,
"trim",
);
const generated = await generateScale32(source);
const { json } = parseNitroBundle(generated.bundle);
expect(
(json.spritesheet as { frames: Record<string, { sourceSize: unknown }> })
.frames.trim_32_a_0_0.sourceSize,
).toEqual({ w: 3, h: 4 });
const pixels = await sharp(
Buffer.from(generated.after.split(",")[1], "base64"),
)
.raw()
.toBuffer();
expect([...pixels.subarray((2 * 3 + 1) * 4, (2 * 3 + 1) * 4 + 4)]).toEqual([
0, 255, 0, 255,
]);
expect(pixels[3]).toBe(0);
});
it("unrotates atlas artwork before generating the smaller frame", async () => {
const source = createNitroBundle(
{
name: "rot",
assets: { rot_64_a_0_0: {} },
visualizations: [{ size: 64 }],
spritesheet: {
frames: {
rot_64_a_0_0: { frame: { x: 0, y: 0, w: 2, h: 6 }, rotated: true },
},
},
},
await sharp({
create: { width: 2, height: 6, channels: 4, background: "#ff0000" },
})
.png()
.toBuffer(),
"rot",
);
const generated = await generateScale32(source);
const size = await sharp(
Buffer.from(generated.after.split(",")[1], "base64"),
).metadata();
expect([size.width, size.height]).toEqual([3, 1]);
});
it("fills an empty scale declaration and retains namespaced frame naming", async () => {
const m = metadata();
const originalFrames = m.spritesheet.frames;
const source = createNitroBundle(
{
...m,
visualizations: [
{ size: 32, layerCount: 1, angle: 45, directions: { 0: {}, 2: {} } },
...m.visualizations,
],
spritesheet: {
...m.spritesheet,
frames: { chair_chair_64_a_0_0: originalFrames.chair_64_a_0_0 },
},
},
await sharp({
create: { width: 6, height: 8, channels: 4, background: "#ff0000" },
})
.png()
.toBuffer(),
"chair",
);
const result = parseNitroBundle((await generateScale32(source)).bundle);
expect(
(result.json.visualizations as { size: number }[]).filter(
(v) => v.size === 32,
),
).toHaveLength(1);
expect(
(result.json.spritesheet as { frames: Record<string, unknown> }).frames
.chair_chair_32_a_0_0,
).toBeDefined();
});
it("handles frame keys with a .png suffix and namespaced naming", async () => {
const m = metadata();
const frames = m.spritesheet.frames as Record<string, unknown>;
for (const key of Object.keys(frames) as string[]) {
frames[`chair_${key}.png`] = frames[key];
delete frames[key];
}
const source = createNitroBundle(
m,
await sharp({
create: { width: 6, height: 8, channels: 4, background: "#ff0000" },
})
.png()
.toBuffer(),
"chair",
);
const result = parseNitroBundle((await generateScale32(source)).bundle);
expect(
(result.json.spritesheet as { frames: Record<string, unknown> }).frames[
"chair_chair_32_a_0_0.png"
],
).toBeDefined();
expect(
(result.json.assets as Record<string, unknown>).chair_32_a_0_0,
).toBeDefined();
});
it("rejects bundles that already contain a non-empty scale32 frame", async () => {
const m = metadata();
const frames = m.spritesheet.frames as Record<string, unknown>;
for (const key of Object.keys(frames) as string[]) {
frames[`chair_${key}.png`] = frames[key];
delete frames[key];
}
(m.assets as Record<string, unknown>).chair_32_a_0_0 = { x: 0, y: 0 };
frames["chair_chair_32_a_0_0.png"] = {
frame: { x: 0, y: 0, w: 3, h: 4 },
rotated: false,
trimmed: false,
spriteSourceSize: { x: 0, y: 0, w: 3, h: 4 },
sourceSize: { w: 3, h: 4 },
};
await expect(generateScale32(await bundle(m))).rejects.toThrow("already");
});