Files
beamboxctl/src/lib/protocol/builders/payload-builder.test.ts
T

471 lines
17 KiB
TypeScript
Raw Normal View History

2026-02-02 18:23:31 +07:00
import { describe, test, expect } from "bun:test";
import { PayloadBuilder } from "./payload-builder.ts";
import { PacketType } from "../packet-types.ts";
import { DEFAULT_PROTOCOL_CONFIG } from "../interfaces/defaults.ts";
import type { ProtocolConfig } from "../interfaces/config.ts";
2026-02-04 12:37:40 +07:00
import type { XV4Frame } from "./xv4-header.ts";
2026-02-02 18:23:31 +07:00
import {
expectHex,
createTestJpeg,
calculateChecksum,
} from "../../../__tests__/utils/test-helpers.ts";
const createBuilder = () => new PayloadBuilder(DEFAULT_PROTOCOL_CONFIG);
describe("PayloadBuilder", () => {
describe("buildImageInfo()", () => {
test("default creates {\"type\":6,\"number\":1}", () => {
const payload = createBuilder().buildImageInfo();
const json = JSON.parse(payload.toString("utf-8"));
expect(json).toEqual({ type: 6, number: 1 });
});
test("output has no extra spaces", () => {
const payload = createBuilder().buildImageInfo();
const text = payload.toString("utf-8");
expect(text).toBe('{"type":6,"number":1}');
});
test("custom type creates {\"type\":5,\"number\":1}", () => {
const payload = createBuilder().buildImageInfo(PacketType.DYNAMIC_AMBIENCE);
const json = JSON.parse(payload.toString("utf-8"));
expect(json).toEqual({ type: 5, number: 1 });
});
test("custom number creates {\"type\":6,\"number\":3}", () => {
const payload = createBuilder().buildImageInfo(PacketType.IMAGE, 3);
const json = JSON.parse(payload.toString("utf-8"));
expect(json).toEqual({ type: 6, number: 3 });
});
test("returns UTF-8 Buffer", () => {
const payload = createBuilder().buildImageInfo();
expect(Buffer.isBuffer(payload)).toBe(true);
const text = payload.toString("utf-8");
expect(text).toMatch(/^\{.*\}$/);
});
test("JSON.parse(output) matches input params", () => {
const imageType = 6;
const number = 1;
const payload = createBuilder().buildImageInfo(imageType, number);
const parsed = JSON.parse(payload.toString("utf-8"));
expect(parsed.type).toBe(imageType);
expect(parsed.number).toBe(number);
});
});
describe("buildImageData()", () => {
test("format starts with {\"type\":6,\"data\"", () => {
const jpeg = createTestJpeg(100);
const payload = createBuilder().buildImageData(jpeg, [64, 32]);
const text = payload.toString("utf-8", 0, 15);
expect(text).toMatch(/^\{"type":6,"data/);
});
test("has correct prefix", () => {
const jpeg = createTestJpeg(100);
const payload = createBuilder().buildImageData(jpeg, [64, 32]);
const prefix = payload.toString("utf-8", 0, 17);
expect(prefix).toBe('{"type":6,"data":');
});
test("suffix is '}'", () => {
const jpeg = createTestJpeg(100);
const payload = createBuilder().buildImageData(jpeg, [64, 32]);
const lastByte = payload.toString("utf-8", payload.length - 1);
expect(lastByte).toBe("}");
});
test("IMB header (36 bytes) present after prefix", () => {
const jpeg = createTestJpeg(100);
const payload = createBuilder().buildImageData(jpeg, [64, 32]);
const prefixLen = '{"type":6,"data":'.length;
const imbSig = payload.toString("utf-8", prefixLen, prefixLen + 3);
expect(imbSig).toBe("IMB");
});
test("JPEG data follows IMB header", () => {
const jpeg = createTestJpeg(100);
const payload = createBuilder().buildImageData(jpeg, [64, 32]);
const prefixLen = '{"type":6,"data":'.length;
const jpegStart = prefixLen + 36;
const jpegInPayload = payload.subarray(jpegStart, jpegStart + jpeg.length);
expect(jpegInPayload.equals(jpeg)).toBe(true);
});
test("total length = prefix + 36 + jpegSize + suffix", () => {
const jpeg = createTestJpeg(100);
const payload = createBuilder().buildImageData(jpeg, [64, 32]);
const prefixLen = '{"type":6,"data":'.length;
const suffixLen = 1;
const expectedLen = prefixLen + 36 + jpeg.length + suffixLen;
expect(payload.length).toBe(expectedLen);
});
test("custom type creates {\"type\":5,\"data\":...}", () => {
const jpeg = createTestJpeg(100);
const payload = createBuilder().buildImageData(
jpeg,
[64, 32],
PacketType.DYNAMIC_AMBIENCE
);
const prefix = payload.toString("utf-8", 0, 17);
expect(prefix).toBe('{"type":5,"data":');
});
test("works with 1-byte JPEG", () => {
const jpeg = Buffer.from([0xff]);
const payload = createBuilder().buildImageData(jpeg, [64, 32]);
const prefixLen = '{"type":6,"data":'.length;
const jpegStart = prefixLen + 36;
expect(payload[jpegStart]).toBe(0xff);
});
test("works with large JPEG (1MB+)", () => {
const jpeg = createTestJpeg(1024 * 1024);
const payload = createBuilder().buildImageData(jpeg, [128, 64]);
const prefixLen = '{"type":6,"data":'.length;
const suffixLen = 1;
const expectedLen = prefixLen + 36 + jpeg.length + suffixLen;
expect(payload.length).toBe(expectedLen);
});
test("dimensions are embedded in IMB header", () => {
const jpeg = createTestJpeg(100);
const payload = createBuilder().buildImageData(jpeg, [128, 64]);
const prefixLen = '{"type":6,"data":'.length;
const imbStart = prefixLen;
const width = payload.readUInt16LE(imbStart + 16);
const height = payload.readUInt16LE(imbStart + 18);
expect(width).toBe(128);
expect(height).toBe(64);
});
});
2026-02-04 12:37:40 +07:00
describe("buildAnimationData()", () => {
test("format starts with {\"type\":5,\"data\"", () => {
const frames: XV4Frame[] = [
{
name: "frame_00001",
data: createTestJpeg(100),
},
];
const payload = createBuilder().buildAnimationData(frames, 50, [368, 368]);
const text = payload.toString("utf-8", 0, 15);
expect(text).toMatch(/^\{"type":5,"data/);
2026-02-02 18:23:31 +07:00
});
2026-02-04 12:37:40 +07:00
test("has correct prefix", () => {
const frames: XV4Frame[] = [
{
name: "frame_00001",
data: createTestJpeg(100),
},
];
const payload = createBuilder().buildAnimationData(frames, 50, [368, 368]);
const prefix = payload.toString("utf-8", 0, 17);
expect(prefix).toBe('{"type":5,"data":');
2026-02-02 18:23:31 +07:00
});
2026-02-04 12:37:40 +07:00
test("suffix is '}'", () => {
const frames: XV4Frame[] = [
{
name: "frame_00001",
data: createTestJpeg(100),
},
];
const payload = createBuilder().buildAnimationData(frames, 50, [368, 368]);
const lastByte = payload.toString("utf-8", payload.length - 1);
expect(lastByte).toBe("}");
});
test("xV4 header present after prefix", () => {
const frames: XV4Frame[] = [
{
name: "frame_00001",
data: createTestJpeg(100),
},
];
const payload = createBuilder().buildAnimationData(frames, 50, [368, 368]);
const prefixLen = '{"type":5,"data":'.length;
const xv4Sig = payload.toString("utf-8", prefixLen, prefixLen + 3);
expect(xv4Sig).toBe("xV4");
});
test("works with multiple frames", () => {
const frames: XV4Frame[] = [
{
name: "frame_00001",
data: createTestJpeg(100),
},
{
name: "frame_00002",
data: createTestJpeg(150),
},
{
name: "frame_00003",
data: createTestJpeg(120),
},
];
const payload = createBuilder().buildAnimationData(frames, 50, [368, 368]);
// Check it has proper structure
const prefixLen = '{"type":5,"data":'.length;
const xv4Sig = payload.toString("utf-8", prefixLen, prefixLen + 3);
expect(xv4Sig).toBe("xV4");
// Check frame count in xV4 header (offset 8, u32 LE)
const frameCount = payload.readUInt32LE(prefixLen + 8);
expect(frameCount).toBe(3);
});
test("custom interval creates correct timing string", () => {
const frames: XV4Frame[] = [
{
name: "frame_00001",
data: createTestJpeg(100),
},
];
const payload = createBuilder().buildAnimationData(frames, 100, [368, 368]);
// The timing string is embedded in the xV4 container
const prefixLen = '{"type":5,"data":'.length;
const xv4Start = prefixLen;
// Timing string is at offset 16 (xV4 + ver + unk + pad + count + size)
const timingString = payload
.subarray(xv4Start + 16, xv4Start + 35)
.toString("utf8")
.replace(/\0.*$/, "");
expect(timingString).toBe("output/100ms");
2026-02-02 18:23:31 +07:00
});
});
describe("createPacket() - header structure", () => {
test("byte 0 is cmdType (0xF1 by default)", () => {
const payload = Buffer.from("test");
const packet = createBuilder().createPacket(payload);
expect(packet[0]).toBe(0xf1);
});
test("byte 1 is cmdSubtype (from config)", () => {
const payload = Buffer.from("test");
const packet = createBuilder().createPacket(payload);
expect(packet[1]).toBe(DEFAULT_PROTOCOL_CONFIG.cmdSubtype);
});
test("byte 1 uses override packetType when provided", () => {
const payload = Buffer.from("test");
const packet = createBuilder().createPacket(payload, 0, 0, PacketType.IMAGE);
expect(packet[1]).toBe(PacketType.IMAGE);
});
test("bytes 2-3 are totalPacketCount (big-endian)", () => {
const payload = Buffer.from("test");
const packet = createBuilder().createPacket(payload, 10, 0);
expect(packet.readUInt16BE(2)).toBe(10);
});
test("bytes 4-5 are remainingPackets (big-endian)", () => {
const payload = Buffer.from("test");
const packet = createBuilder().createPacket(payload, 10, 5);
expect(packet.readUInt16BE(4)).toBe(5);
});
test("bytes 6-7 are payload length (big-endian)", () => {
const payload = Buffer.from("test");
const packet = createBuilder().createPacket(payload);
expect(packet.readUInt16BE(6)).toBe(4);
});
});
describe("createPacket() - checksum", () => {
test("last byte is checksum", () => {
const payload = Buffer.from("test");
const packet = createBuilder().createPacket(payload);
const checksumByte = packet[packet.length - 1];
expect(typeof checksumByte).toBe("number");
});
test("checksum = (-sum(header + payload)) & 0xFF", () => {
const payload = Buffer.from("test");
const packet = createBuilder().createPacket(payload);
const headerAndPayload = packet.subarray(0, packet.length - 1);
const expectedChecksum = calculateChecksum(headerAndPayload);
const actualChecksum = packet[packet.length - 1];
expect(actualChecksum).toBe(expectedChecksum);
});
test("different payloads produce different checksums", () => {
const builder = createBuilder();
const packet1 = builder.createPacket(Buffer.from("test1"));
const packet2 = builder.createPacket(Buffer.from("test2"));
const checksum1 = packet1[packet1.length - 1];
const checksum2 = packet2[packet2.length - 1];
expect(checksum1).not.toBe(checksum2);
});
test("same payload produces same checksum", () => {
const payload = Buffer.from("test");
const builder = createBuilder();
const packet1 = builder.createPacket(payload);
const packet2 = builder.createPacket(payload);
const checksum1 = packet1[packet1.length - 1];
const checksum2 = packet2[packet2.length - 1];
expect(checksum1).toBe(checksum2);
});
});
describe("createPacket() - parameters", () => {
test("totalPacketCount=0, remainingPackets=0 (info packet)", () => {
const payload = Buffer.from('{"type":6,"number":1}');
const packet = createBuilder().createPacket(payload, 0, 0);
expect(packet.readUInt16BE(2)).toBe(0);
expect(packet.readUInt16BE(4)).toBe(0);
});
test("totalPacketCount=1, remainingPackets=0 (single data packet)", () => {
const payload = Buffer.from("data");
const packet = createBuilder().createPacket(payload, 1, 0);
expect(packet.readUInt16BE(2)).toBe(1);
expect(packet.readUInt16BE(4)).toBe(0);
});
test("totalPacketCount=10, remainingPackets=9 (first of 10)", () => {
const payload = Buffer.from("chunk1");
const packet = createBuilder().createPacket(payload, 10, 9);
expect(packet.readUInt16BE(2)).toBe(10);
expect(packet.readUInt16BE(4)).toBe(9);
});
test("totalPacketCount=10, remainingPackets=0 (last of 10)", () => {
const payload = Buffer.from("chunk10");
const packet = createBuilder().createPacket(payload, 10, 0);
expect(packet.readUInt16BE(2)).toBe(10);
expect(packet.readUInt16BE(4)).toBe(0);
});
test("override packetType changes byte 1", () => {
const payload = Buffer.from("test");
const packet = createBuilder().createPacket(
payload,
0,
0,
PacketType.DEVICE_STATUS
);
expect(packet[1]).toBe(PacketType.DEVICE_STATUS);
});
});
describe("createPacket() - edge cases", () => {
test("empty payload (length=0)", () => {
const payload = Buffer.alloc(0);
const packet = createBuilder().createPacket(payload);
expect(packet.readUInt16BE(6)).toBe(0);
expect(packet.length).toBe(9);
});
test("1-byte payload", () => {
const payload = Buffer.from([0x42]);
const packet = createBuilder().createPacket(payload);
expect(packet.readUInt16BE(6)).toBe(1);
expect(packet[8]).toBe(0x42);
});
test("large payload (512 bytes)", () => {
const payload = Buffer.alloc(512, 0xaa);
const packet = createBuilder().createPacket(payload);
expect(packet.readUInt16BE(6)).toBe(512);
expect(packet.length).toBe(8 + 512 + 1);
});
test("values overflow handling (65535+ packets)", () => {
const payload = Buffer.from("test");
const packet = createBuilder().createPacket(payload, 70000, 70000);
const totalPackets = packet.readUInt16BE(2);
const remaining = packet.readUInt16BE(4);
expect(totalPackets).toBe(70000 & 0xffff);
expect(remaining).toBe(70000 & 0xffff);
});
});
describe("createPacket() - structure", () => {
test("packet = header(8) + payload + checksum(1)", () => {
const payload = Buffer.from("test data");
const packet = createBuilder().createPacket(payload);
expect(packet.length).toBe(8 + payload.length + 1);
});
test("payload is correctly embedded", () => {
const payload = Buffer.from("test data");
const packet = createBuilder().createPacket(payload);
const embeddedPayload = packet.subarray(8, 8 + payload.length);
expect(embeddedPayload.equals(payload)).toBe(true);
});
test("image info packet structure", () => {
const infoPayload = Buffer.from('{"type":6,"number":1}');
const packet = createBuilder().createPacket(infoPayload, 0, 0, PacketType.IMAGE);
expect(packet[0]).toBe(0xf1);
expect(packet[1]).toBe(PacketType.IMAGE);
expect(packet.readUInt16BE(2)).toBe(0);
expect(packet.readUInt16BE(4)).toBe(0);
expect(packet.readUInt16BE(6)).toBe(infoPayload.length);
const embeddedPayload = packet.subarray(8, 8 + infoPayload.length);
expect(embeddedPayload.equals(infoPayload)).toBe(true);
});
});
describe("integration - upload sequence", () => {
test("build info + data packets for single image", () => {
const builder = createBuilder();
const infoPayload = builder.buildImageInfo();
const infoPacket = builder.createPacket(infoPayload, 0, 0, PacketType.IMAGE);
expect(infoPacket[0]).toBe(0xf1);
expect(infoPacket[1]).toBe(PacketType.IMAGE);
const jpeg = createTestJpeg(200);
const dataPayload = builder.buildImageData(jpeg, [64, 32]);
const dataPacket = builder.createPacket(dataPayload, 1, 0);
expect(dataPacket[0]).toBe(0xf1);
expect(dataPacket.readUInt16BE(2)).toBe(1);
expect(dataPacket.readUInt16BE(4)).toBe(0);
});
test("build multi-packet data sequence", () => {
const builder = createBuilder();
const jpeg = createTestJpeg(1000);
const fullData = builder.buildImageData(jpeg, [64, 32]);
const chunkSize = 512;
const totalChunks = Math.ceil(fullData.length / chunkSize);
const packets = [];
for (let i = 0; i < totalChunks; i++) {
const start = i * chunkSize;
const end = Math.min(start + chunkSize, fullData.length);
const chunk = fullData.subarray(start, end);
const remaining = totalChunks - 1 - i;
const packet = builder.createPacket(chunk, totalChunks, remaining);
packets.push(packet);
expect(packet.readUInt16BE(2)).toBe(totalChunks);
expect(packet.readUInt16BE(4)).toBe(remaining);
}
expect(packets.length).toBe(totalChunks);
expect(packets[0]!.readUInt16BE(4)).toBe(totalChunks - 1);
expect(packets[packets.length - 1]!.readUInt16BE(4)).toBe(0);
});
});
});