Files
suaveplan/packages/foundation/bitpacker/bitpacker-strategies/src/tests/strategies.test.ts
2026-05-27 23:47:53 +01:00

1944 lines
68 KiB
TypeScript

import {
BitPacker,
type CustomTypeHandler,
CustomTypeRegistry,
type PackingContext,
} from "@suaveplan/bitpacker";
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { BitPackerSchema } from "@suaveplan/types";
import { registerDefaultStrategies } from "../register-default-strategies/register-default-strategies.js";
import { ArrayPackingStrategy } from "../strategies/array/array.js";
import {
ArrayConfigPackingStrategy,
isArrayConfig,
validateArrayData,
} from "../strategies/array-config/array-config.js";
import {
ArrayNotationPackingStrategy,
getArrayLength,
getElementSchema,
parseArraySchema,
} from "../strategies/array-notation/array-notation.js";
import { ArraySchemaPackingStrategy } from "../strategies/array-schema/array-schema.js";
import { BigIntPackingStrategy } from "../strategies/bigint/bigint.js";
import { BitsetPackingStrategy } from "../strategies/bitset/bitset.js";
import {
ConditionalPackingStrategy,
evaluatePredicate,
isConditionalSchema,
} from "../strategies/conditional/conditional.js";
import { DateTimePackingStrategy } from "../strategies/datetime/datetime.js";
import { EnumPackingStrategy } from "../strategies/enum/enum.js";
import { MapPackingStrategy } from "../strategies/map/map.js";
import { NumberPackingStrategy } from "../strategies/number/number.js";
import { OneOfPackingStrategy } from "../strategies/oneOf/oneOf.js";
import { OptionalSuffixPackingStrategy } from "../strategies/optional-suffix/optional-suffix.js";
import { SetPackingStrategy } from "../strategies/set/set.js";
import { StringPackingStrategy } from "../strategies/string/string.js";
import { UnionPackingStrategy } from "../strategies/union/union.js";
function createBitPacker(): BitPacker {
const bp = new BitPacker();
registerDefaultStrategies(bp.strategyManager);
return bp;
}
describe("Default strategies", () => {
describe("number", () => {
it("round-trips unsigned integer", () => {
const bp = createBitPacker();
const packed = bp.pack(42, 255);
expect(bp.unpack(packed, 255)).toBe(42);
});
it("round-trips signed integer", () => {
const bp = createBitPacker();
const packed = bp.pack(-50, -127);
expect(bp.unpack(packed, -127)).toBe(-50);
});
it("round-trips fixed-point", () => {
const bp = createBitPacker();
const packed = bp.pack(1.5, 4.2);
expect(bp.unpack(packed, 4.2)).toBeCloseTo(1.5, 2);
});
it("round-trips schema 0", () => {
const bp = createBitPacker();
expect(bp.unpack(bp.pack(0, 0), 0)).toBe(0);
});
it("throws for schema 0 with non-zero value", () => {
const bp = createBitPacker();
expect(() => bp.pack(1, 0)).toThrow();
});
it("throws for value out of range unsigned", () => {
const bp = createBitPacker();
expect(() => bp.pack(300, 255)).toThrow();
});
it("throws for value out of range signed", () => {
const bp = createBitPacker();
expect(() => bp.pack(-200, -127)).toThrow();
});
it("round-trips signed negative value", () => {
const bp = createBitPacker();
expect(bp.unpack(bp.pack(-100, -127), -127)).toBe(-100);
});
it("round-trips positive signed integer", () => {
const bp = createBitPacker();
expect(bp.unpack(bp.pack(50, -127), -127)).toBe(50);
});
it("round-trips signed fixed-point number schema", () => {
const bp = createBitPacker();
expect(bp.unpack(bp.pack(-1.5, -4.2), -4.2)).toBeCloseTo(-1.5, 2);
});
it("throws for fixed-point value out of range", () => {
const bp = createBitPacker();
expect(() => bp.pack(1000, 4.2)).toThrow();
});
it("packs signed fixed-point directly", () => {
const strategy = new NumberPackingStrategy();
const writer = { writeInt: () => {}, writeUint: () => {} };
const context = { writer } as unknown as PackingContext;
strategy.pack(-1.5, -4.2, context);
});
});
describe("string", () => {
it("round-trips fixed-length string", () => {
const bp = createBitPacker();
const packed = bp.pack("hello", "8");
expect(bp.unpack(packed, "8")).toBe("hello");
});
it("round-trips variable-length string", () => {
const bp = createBitPacker();
const packed = bp.pack("hi", "-16");
expect(bp.unpack(packed, "-16")).toBe("hi");
});
it("round-trips UUID", () => {
const bp = createBitPacker();
const uuid = "550e8400-e29b-41d4-a716-446655440000";
const packed = bp.pack(uuid, "uuid");
expect(bp.unpack(packed, "uuid")).toBe(uuid);
});
it("round-trips UUID as Uint8Array", () => {
const bp = createBitPacker();
const uuid = "550e8400-e29b-41d4-a716-446655440000";
const bytes = new Uint8Array(16);
const hex = uuid.replace(/-/g, "");
for (let i = 0; i < 16; i++) {
bytes[i] = Number.parseInt(hex.substring(i * 2, i * 2 + 2), 16);
}
expect(bp.unpack(bp.pack(bytes, "uuid"), "uuid")).toBe(uuid);
});
it("throws for invalid UUID string", () => {
const bp = createBitPacker();
expect(() => bp.pack("not-a-uuid", "uuid")).toThrow();
});
it("throws for invalid UUID type", () => {
const bp = createBitPacker();
expect(() => bp.pack(12345, "uuid")).toThrow();
});
it("round-trips f32", () => {
const bp = createBitPacker();
const packed = bp.pack(3.14, "f32");
expect(bp.unpack(packed, "f32")).toBeCloseTo(3.14, 5);
});
it("round-trips f64", () => {
const bp = createBitPacker();
const packed = bp.pack(Math.E, "f64");
expect(bp.unpack(packed, "f64")).toBe(Math.E);
});
it("throws for float non-number", () => {
const bp = createBitPacker();
expect(() => bp.pack("pi", "f32")).toThrow();
});
it("throws for fixed string non-string", () => {
const bp = createBitPacker();
expect(() => bp.pack(42, "8")).toThrow();
});
it("throws for fixed string too long", () => {
const bp = createBitPacker();
expect(() => bp.pack("this is way too long", "4")).toThrow();
});
it("round-trips fixed-float string schema", () => {
const bp = createBitPacker();
expect(bp.unpack(bp.pack(1.5, "4.2"), "4.2")).toBeCloseTo(1.5, 2);
});
it("round-trips signed fixed-float string schema", () => {
const bp = createBitPacker();
expect(bp.unpack(bp.pack(-1.5, "-4.2"), "-4.2")).toBeCloseTo(
-1.5,
2
);
});
it("round-trips fixed-float with string value", () => {
const bp = createBitPacker();
expect(bp.unpack(bp.pack("1.5", "4.2"), "4.2")).toBeCloseTo(1.5, 2);
});
it("round-trips empty string with schema 0", () => {
const bp = createBitPacker();
expect(bp.unpack(bp.pack("", "0"), "0")).toBe("");
});
it("packs variable string -0 directly", () => {
const strategy = new StringPackingStrategy();
const writer = {
writeUint: () => {},
writeInt: () => {},
writeBool: () => {},
writeBytes: () => {},
};
const context = { writer } as unknown as PackingContext;
strategy.pack("", "-0", context);
});
it("unpacks variable string -0 directly", () => {
const strategy = new StringPackingStrategy();
const reader = { readUint: () => 0 };
const context = { reader } as unknown as PackingContext;
expect(strategy.unpack("-0", context)).toBe("");
});
});
describe("boolean", () => {
it("round-trips true", () => {
const bp = createBitPacker();
expect(bp.unpack(bp.pack(true, true), true)).toBe(true);
});
it("round-trips false", () => {
const bp = createBitPacker();
expect(bp.unpack(bp.pack(false, true), true)).toBe(false);
});
});
describe("null", () => {
it("packs null with zero bits", () => {
const bp = createBitPacker();
const packed = bp.pack(null, null);
expect(packed.length).toBe(0);
expect(bp.unpack(packed, null)).toBe(null);
});
});
describe("bigint", () => {
it("round-trips signed bigint", () => {
const bp = createBitPacker();
const packed = bp.pack(BigInt(-9007199254740993), "bigint-i64");
expect(bp.unpack(packed, "bigint-i64")).toBe(
BigInt(-9007199254740993)
);
});
it("round-trips unsigned bigint", () => {
const bp = createBitPacker();
const packed = bp.pack(BigInt(18446744073709551615n), "bigint-u64");
expect(bp.unpack(packed, "bigint-u64")).toBe(
BigInt(18446744073709551615n)
);
});
it("round-trips bigint array", () => {
const bp = createBitPacker();
const values = [1n, 2n, 3n];
const packed = bp.pack(values, "bigint-i64[]");
expect(bp.unpack(packed, "bigint-i64[]")).toEqual(values);
});
it("throws for non-bigint data", () => {
const bp = createBitPacker();
expect(() => bp.pack(42, "bigint-i64")).toThrow();
});
it("packs bigint array directly", () => {
const strategy = new BigIntPackingStrategy();
const writer = {
writeBytes: () => {},
writeUint: () => {},
};
const context = { writer } as unknown as PackingContext;
strategy.pack([1n, 2n], "bigint-i64[]", context);
});
it("unpacks bigint array directly", () => {
const strategy = new BigIntPackingStrategy();
let _calls = 0;
const reader = {
readBytes: () => {
_calls++;
const buf = new ArrayBuffer(8);
const view = new DataView(buf);
view.setBigInt64(0, 42n, false);
return new Uint8Array(buf);
},
readUint: () => 2,
};
const context = { reader } as unknown as PackingContext;
const result = strategy.unpack("bigint-i64[]", context);
expect(result).toEqual([42n, 42n]);
});
it("throws for non-array bigint array data", () => {
const strategy = new BigIntPackingStrategy();
const context = {} as unknown as PackingContext;
expect(() =>
strategy.pack("not array", "bigint-i64[]", context)
).toThrow();
});
});
describe("bigint-array", () => {
it("round-trips BigInt64Array", () => {
const bp = createBitPacker();
const data = new BigInt64Array([1n, 2n, 3n]);
expect(
bp.unpack(bp.pack(data, "bigint-i64[]"), "bigint-i64[]")
).toEqual([1n, 2n, 3n]);
});
it("round-trips BigUint64Array", () => {
const bp = createBitPacker();
const data = new BigUint64Array([1n, 2n, 3n]);
expect(
bp.unpack(bp.pack(data, "bigint-u64[]"), "bigint-u64[]")
).toEqual([1n, 2n, 3n]);
});
it("throws for array too long", () => {
const bp = createBitPacker();
const data = new Array(65536).fill(0n);
expect(() => bp.pack(data, "bigint-i64[]")).toThrow();
});
it("throws for non-array data", () => {
const bp = createBitPacker();
expect(() => bp.pack("bad", "bigint-i64[]")).toThrow();
});
});
describe("object", () => {
it("round-trips flat object", () => {
const bp = createBitPacker();
const schema = { x: 255, y: "-16", flag: true };
const data = { x: 100, y: "hello", flag: false };
const packed = bp.pack(data, schema);
expect(bp.unpack(packed, schema)).toEqual(data);
});
it("round-trips nested object", () => {
const bp = createBitPacker();
const schema = {
pos: { x: 255, y: 255 },
name: "-8",
};
const data = { pos: { x: 10, y: 20 }, name: "A" };
const packed = bp.pack(data, schema);
expect(bp.unpack(packed, schema)).toEqual(data);
});
it("throws for non-object data", () => {
const bp = createBitPacker();
expect(() => bp.pack("bad", { x: 255 })).toThrow();
});
it("handles undefined schema fields gracefully", () => {
const bp = createBitPacker();
const schema = {
x: undefined,
y: 255,
} as unknown as BitPackerSchema;
const data = { y: 10 };
expect(bp.unpack(bp.pack(data, schema), schema)).toEqual({ y: 10 });
});
it("handles missing object keys", () => {
const bp = createBitPacker();
const schema = { x: 255, y: { _optional: 255 } };
const data = { x: 10 };
expect(bp.unpack(bp.pack(data, schema), schema)).toEqual({
x: 10,
y: undefined,
});
});
});
describe("array", () => {
it("round-trips variable-length array", () => {
const bp = createBitPacker();
const schema: BitPackerSchema = [255];
const data = [1, 2, 3];
const packed = bp.pack(data, schema);
expect(bp.unpack(packed, schema)).toEqual(data);
});
it("round-trips array with max length", () => {
const bp = createBitPacker();
const schema: BitPackerSchema = [10, 255];
const data = [5, 6];
const packed = bp.pack(data, schema);
expect(bp.unpack(packed, schema)).toEqual(data);
});
it("throws for non-array data", () => {
const bp = createBitPacker();
expect(() => bp.pack("not array", [255])).toThrow();
});
it("throws when array exceeds max length", () => {
const bp = createBitPacker();
const schema = [2, 255];
expect(() => bp.pack([1, 2, 3], schema)).toThrow();
});
it("packs array with maxLength 0 directly", () => {
const strategy = new ArrayPackingStrategy();
const writer = {
writeUint: () => {},
writeInt: () => {},
writeBool: () => {},
writeBytes: () => {},
};
const context = {
writer,
packValue: () => {},
} as unknown as PackingContext;
strategy.pack([], [0, 255], context);
});
});
describe("array-schema (tuple)", () => {
it("round-trips tuple array", () => {
const bp = createBitPacker();
const schema = [255, "-8", true];
const data = [42, "hi", false];
expect(bp.unpack(bp.pack(data, schema), schema)).toEqual(data);
});
it("throws when tuple schema item is undefined", () => {
const strategy = new ArraySchemaPackingStrategy();
const writer = {
writeUint: () => {},
writeInt: () => {},
writeBool: () => {},
writeBytes: () => {},
};
const context = {
writer,
packValue: () => {},
} as unknown as PackingContext;
const schema = [255, undefined, true] as unknown as BitPackerSchema;
expect(() => strategy.pack([1, 2, 3], schema, context)).toThrow();
});
it("packs tuple with non-array data", () => {
const strategy = new ArraySchemaPackingStrategy();
const writer = {
writeUint: () => {},
writeInt: () => {},
writeBool: () => {},
writeBytes: () => {},
};
const context = {
writer,
packValue: () => {},
} as unknown as PackingContext;
strategy.pack("not array", [255], context);
});
});
describe("array-config", () => {
it("round-trips integer array config", () => {
const bp = createBitPacker();
const schema: BitPackerSchema = {
type: "array",
maxLength: 5,
elementType: "integer",
elementMax: 10,
};
const data = [1, 2, 3];
expect(bp.unpack(bp.pack(data, schema), schema)).toEqual(data);
});
it("round-trips float array config", () => {
const bp = createBitPacker();
const schema: BitPackerSchema = {
type: "array",
maxLength: 5,
elementType: "float",
elementMax: 100,
};
const data = [1, 2, 3];
expect(bp.unpack(bp.pack(data, schema), schema)).toEqual(data);
});
it("round-trips boolean array config", () => {
const bp = createBitPacker();
const schema: BitPackerSchema = {
type: "array",
maxLength: 5,
elementType: "boolean",
};
const data = [true, false];
expect(bp.unpack(bp.pack(data, schema), schema)).toEqual(data);
});
it("round-trips schema array config", () => {
const bp = createBitPacker();
const schema: BitPackerSchema = {
type: "array",
maxLength: 5,
elementType: "schema",
elementSchema: 255,
};
const data = [1, 2, 3];
expect(bp.unpack(bp.pack(data, schema), schema)).toEqual(data);
});
it("round-trips string array config", () => {
const bp = createBitPacker();
const schema: BitPackerSchema = {
type: "array",
maxLength: 3,
elementType: "string",
elementMax: 4,
};
const data = ["hi", "ho"];
expect(bp.unpack(bp.pack(data, schema), schema)).toEqual(data);
});
it("round-trips integer array config without elementMax", () => {
const bp = createBitPacker();
const schema: BitPackerSchema = {
type: "array",
maxLength: 5,
elementType: "integer",
};
const data = [1, 2, 3];
expect(bp.unpack(bp.pack(data, schema), schema)).toEqual(data);
});
it("round-trips float array config without elementMax", () => {
const bp = createBitPacker();
const schema: BitPackerSchema = {
type: "array",
maxLength: 5,
elementType: "float",
};
const data = [1, 2, 3];
expect(bp.unpack(bp.pack(data, schema), schema)).toEqual(data);
});
it("round-trips string array config without elementMax", () => {
const bp = createBitPacker();
const schema: BitPackerSchema = {
type: "array",
maxLength: 2,
elementType: "string",
};
const data = ["hi", "ho"];
expect(bp.unpack(bp.pack(data, schema), schema)).toEqual(data);
});
it("throws for non-array data", () => {
const bp = createBitPacker();
const schema: BitPackerSchema = {
type: "array",
maxLength: 5,
elementType: "integer",
};
expect(() => bp.pack("bad", schema)).toThrow();
});
it("throws for invalid element data", () => {
const bp = createBitPacker();
const schema: BitPackerSchema = {
type: "array",
maxLength: 5,
elementType: "integer",
};
expect(() => bp.pack([1.5], schema)).toThrow();
});
it("isArrayConfig rejects non-objects", () => {
expect(isArrayConfig("bad")).toBe(false);
expect(isArrayConfig(null)).toBe(false);
expect(isArrayConfig([])).toBe(false);
});
it("isArrayConfig rejects missing fields", () => {
expect(isArrayConfig({ type: "array" })).toBe(false);
expect(
isArrayConfig({
type: "array",
maxLength: 5,
elementType: "bad",
})
).toBe(false);
expect(
isArrayConfig({
type: "array",
maxLength: 5,
elementType: "schema",
})
).toBe(false);
});
it("validateArrayData handles default case", () => {
expect(
validateArrayData([1], {
type: "array",
maxLength: 5,
elementType: "bad",
} as unknown as Parameters<typeof validateArrayData>[1])
).toBe(false);
});
it("validateArrayData rejects non-array", () => {
expect(
validateArrayData(
"bad" as unknown as unknown[],
{
type: "array",
maxLength: 5,
elementType: "integer",
} as unknown as Parameters<typeof validateArrayData>[1]
)
).toBe(false);
});
it("validateArrayData rejects too long array", () => {
expect(
validateArrayData([1, 2, 3, 4, 5, 6], {
type: "array",
maxLength: 5,
elementType: "integer",
} as unknown as Parameters<typeof validateArrayData>[1])
).toBe(false);
});
it("packs array config with maxLength 0 directly", () => {
const strategy = new ArrayConfigPackingStrategy();
const writer = {
writeUint: () => {},
writeInt: () => {},
writeBool: () => {},
writeBytes: () => {},
};
const context = {
writer,
packValue: () => {},
} as unknown as PackingContext;
strategy.pack(
[],
{
type: "array",
maxLength: 0,
elementType: "integer",
} as unknown as BitPackerSchema,
context
);
});
});
describe("array-notation", () => {
it("round-trips integer elements", () => {
const bp = createBitPacker();
expect(bp.unpack(bp.pack([1, 2, 3], "-5[10]"), "-5[10]")).toEqual([
1, 2, 3,
]);
});
it("round-trips float elements", () => {
const bp = createBitPacker();
expect(bp.unpack(bp.pack([1, 2], "-5[4.2]"), "-5[4.2]")).toEqual([
1, 2,
]);
});
it("round-trips boolean elements", () => {
const bp = createBitPacker();
expect(
bp.unpack(bp.pack([true, false], "-5[true]"), "-5[true]")
).toEqual([true, false]);
});
it("round-trips object elements", () => {
const bp = createBitPacker();
expect(
bp.unpack(
bp.pack([{ x: 1 }, { x: 2 }], '-5[{"x":10}]'),
'-5[{"x":10}]'
)
).toEqual([{ x: 1 }, { x: 2 }]);
});
it("round-trips nested array notation", () => {
const bp = createBitPacker();
expect(
bp.unpack(bp.pack([[1], [2]], "-5[-3[10]]"), "-5[-3[10]]")
).toEqual([[1], [2]]);
});
it("throws for invalid array notation schema", () => {
const bp = createBitPacker();
expect(() => bp.pack([1], "-5[unknown]")).toThrow();
});
it("throws for non-array data", () => {
const bp = createBitPacker();
expect(() => bp.pack("bad", "-5[10]")).toThrow();
});
it("throws when data too long", () => {
const bp = createBitPacker();
expect(() => bp.pack([1, 2, 3, 4, 5, 6], "-3[10]")).toThrow();
});
it("getArrayLength parses length", () => {
expect(getArrayLength("-5[10]")).toBe(5);
});
it("getArrayLength throws for invalid schema", () => {
expect(() => getArrayLength("bad")).toThrow();
});
it("getElementSchema extracts inner schema", () => {
expect(getElementSchema("-5[10]")).toBe("10");
});
it("getElementSchema throws for invalid schema", () => {
expect(() => getElementSchema("bad")).toThrow();
});
it("parseArraySchema returns null for invalid notation", () => {
expect(parseArraySchema("bad")).toBeNull();
});
it("parseArraySchema handles nested arrays", () => {
const result = parseArraySchema("-5[-3[10]]");
expect(result).not.toBeNull();
expect(result?.elementType).toBe("schema");
});
it("parseArraySchema handles object schemas", () => {
const result = parseArraySchema('-5[{"x":10}]');
expect(result).not.toBeNull();
expect(result?.elementType).toBe("schema");
});
it("parseArraySchema returns null for invalid JSON object", () => {
expect(parseArraySchema("-5[{bad}]")).toBeNull();
});
it("throws on unpack for invalid array notation schema", () => {
const strategy = new ArrayNotationPackingStrategy();
const reader = { readUint: () => 0 };
const context = { reader } as unknown as PackingContext;
expect(() => strategy.unpack("-5[unknown]", context)).toThrow();
});
it("packs array notation with maxLength 0 directly", () => {
const strategy = new ArrayNotationPackingStrategy();
const writer = {
writeUint: () => {},
writeInt: () => {},
writeBool: () => {},
writeBytes: () => {},
};
const context = {
writer,
packValue: () => {},
} as unknown as PackingContext;
strategy.pack([], "-0[10]", context);
});
it("unpacks array notation with maxLength 0 directly", () => {
const strategy = new ArrayNotationPackingStrategy();
const reader = { readUint: () => 0 };
const context = { reader } as unknown as PackingContext;
expect(strategy.unpack("-0[10]", context)).toEqual([]);
});
});
describe("enum", () => {
it("round-trips enum value", () => {
const bp = createBitPacker();
const schema: BitPackerSchema = { _enum: ["red", "green", "blue"] };
const packed = bp.pack("green", schema);
expect(bp.unpack(packed, schema)).toBe("green");
});
it("packs single-value enum directly", () => {
const strategy = new EnumPackingStrategy();
const writer = { writeUint: () => {} };
const context = { writer } as unknown as PackingContext;
strategy.pack("only", { _enum: ["only"] }, context);
});
it("throws for non-string data", () => {
const bp = createBitPacker();
const schema = { _enum: ["a", "b"] };
expect(() => bp.pack(42, schema)).toThrow();
});
it("throws for unknown enum value", () => {
const bp = createBitPacker();
const schema = { _enum: ["a", "b"] };
expect(() => bp.pack("c", schema)).toThrow();
});
it("throws on unpack when index out of range", () => {
const strategy = new EnumPackingStrategy();
const reader = { readUint: () => 5 };
const context = { reader } as unknown as PackingContext;
expect(() =>
strategy.unpack({ _enum: ["a", "b"] }, context)
).toThrow();
});
});
describe("optional", () => {
it("round-trips present optional", () => {
const bp = createBitPacker();
const schema: BitPackerSchema = { _optional: 255 };
expect(bp.unpack(bp.pack(42, schema), schema)).toBe(42);
});
it("round-trips absent optional", () => {
const bp = createBitPacker();
const schema: BitPackerSchema = { _optional: 255 };
expect(
bp.unpack(bp.pack(undefined, schema), schema)
).toBeUndefined();
});
});
describe("optional-marker", () => {
it("round-trips present value", () => {
const bp = createBitPacker();
const schema = { _optional: true, _schema: 255 };
expect(bp.unpack(bp.pack(42, schema), schema)).toBe(42);
});
it("returns undefined when absent", () => {
const bp = createBitPacker();
const schema = { _optional: true, _schema: 255 };
expect(
bp.unpack(bp.pack(undefined, schema), schema)
).toBeUndefined();
});
it("returns undefined when null", () => {
const bp = createBitPacker();
const schema = { _optional: true, _schema: 255 };
expect(bp.unpack(bp.pack(null, schema), schema)).toBeUndefined();
});
it("returns default when absent", () => {
const bp = createBitPacker();
const schema = { _optional: true, _schema: 255, _default: 99 };
expect(bp.unpack(bp.pack(undefined, schema), schema)).toBe(99);
});
});
describe("optional-suffix", () => {
it("round-trips uuid?", () => {
const bp = createBitPacker();
const uuid = "550e8400-e29b-41d4-a716-446655440000";
expect(bp.unpack(bp.pack(uuid, "uuid?"), "uuid?")).toBe(uuid);
});
it("returns undefined for absent uuid?", () => {
const bp = createBitPacker();
expect(
bp.unpack(bp.pack(undefined, "uuid?"), "uuid?")
).toBeUndefined();
});
it("round-trips fixed string 8?", () => {
const bp = createBitPacker();
expect(bp.unpack(bp.pack("hello", "8?"), "8?")).toBe("hello");
});
it("round-trips variable string -8?", () => {
const bp = createBitPacker();
expect(bp.unpack(bp.pack("hi", "-8?"), "-8?")).toBe("hi");
});
it("round-trips varint?", () => {
const bp = createBitPacker();
expect(bp.unpack(bp.pack(150, "varint?"), "varint?")).toBe(150);
});
it("isOptionalSuffix rejects non-string", () => {
expect(OptionalSuffixPackingStrategy.isOptionalSuffix(42)).toBe(
false
);
});
it("isOptionalSuffix rejects missing base", () => {
expect(OptionalSuffixPackingStrategy.isOptionalSuffix("?")).toBe(
false
);
});
it("isOptionalSuffix rejects double suffix", () => {
expect(
OptionalSuffixPackingStrategy.isOptionalSuffix("hello??")
).toBe(false);
});
it("baseSchema strips suffix", () => {
expect(OptionalSuffixPackingStrategy.baseSchema("hello?")).toBe(
"hello"
);
});
});
describe("oneOf", () => {
it("round-trips string branch", () => {
const bp = createBitPacker();
const schema: BitPackerSchema = { _oneOf: ["-8", 255] };
expect(bp.unpack(bp.pack("hello", schema), schema)).toBe("hello");
});
it("round-trips number branch", () => {
const bp = createBitPacker();
const schema: BitPackerSchema = { _oneOf: ["-8", 255] };
expect(bp.unpack(bp.pack(42, schema), schema)).toBe(42);
});
it("round-trips boolean branch", () => {
const bp = createBitPacker();
const schema: BitPackerSchema = { _oneOf: [true, 255] };
expect(bp.unpack(bp.pack(false, schema), schema)).toBe(false);
});
it("round-trips null branch", () => {
const bp = createBitPacker();
const schema: BitPackerSchema = { _oneOf: [null, 255] };
expect(bp.unpack(bp.pack(null, schema), schema)).toBe(null);
});
it("round-trips array branch", () => {
const bp = createBitPacker();
const schema: BitPackerSchema = { _oneOf: [[255], "-8"] };
expect(bp.unpack(bp.pack([1, 2], schema), schema)).toEqual([1, 2]);
});
it("round-trips object branch", () => {
const bp = createBitPacker();
const schema: BitPackerSchema = { _oneOf: [{ x: 255 }, "-8"] };
expect(bp.unpack(bp.pack({ x: 5 }, schema), schema)).toEqual({
x: 5,
});
});
it("round-trips nested oneOf branch", () => {
const bp = createBitPacker();
const schema: BitPackerSchema = {
_oneOf: [{ _oneOf: [true, 255] }, "-8"],
};
expect(bp.unpack(bp.pack(true, schema), schema)).toBe(true);
});
it("round-trips optional branch", () => {
const bp = createBitPacker();
const schema: BitPackerSchema = {
_oneOf: [{ _optional: 255 }, "-8"],
};
expect(bp.unpack(bp.pack(42, schema), schema)).toBe(42);
});
it("round-trips enum branch", () => {
const bp = createBitPacker();
const schema: BitPackerSchema = {
_oneOf: [{ _enum: ["a", "b"] }, "-8"],
};
expect(bp.unpack(bp.pack("a", schema), schema)).toBe("a");
});
it("throws when no branch matches", () => {
const bp = createBitPacker();
const schema: BitPackerSchema = { _oneOf: [true, 255] };
expect(() => bp.pack("hello", schema)).toThrow();
});
it("throws on unpack when index out of range", () => {
const strategy = new OneOfPackingStrategy();
const reader = { readUint: () => 5 };
const context = {
reader,
unpackValue: () => {},
} as unknown as PackingContext;
expect(() =>
strategy.unpack({ _oneOf: [255, "-8"] }, context)
).toThrow();
});
it("#canPack returns false for unsupported schema types", () => {
const bp = createBitPacker();
const schema = {
_oneOf: [123n as unknown as BitPackerSchema, 255],
};
expect(bp.unpack(bp.pack(42, schema), schema)).toBe(42);
});
it("packs oneOf with single branch directly", () => {
const strategy = new OneOfPackingStrategy();
const writer = { writeUint: () => {} };
const context = {
writer,
packValue: () => {},
} as unknown as PackingContext;
strategy.pack(42, { _oneOf: [255] }, context);
});
});
describe("varint", () => {
it("round-trips unsigned varint", () => {
const bp = createBitPacker();
expect(bp.unpack(bp.pack(150, "varint"), "varint")).toBe(150);
});
it("round-trips signed varint", () => {
const bp = createBitPacker();
expect(bp.unpack(bp.pack(-150, "-varint"), "-varint")).toBe(-150);
});
it("throws for non-integer data", () => {
const bp = createBitPacker();
expect(() => bp.pack(1.5, "varint")).toThrow();
});
it("throws for negative unsigned varint", () => {
const bp = createBitPacker();
expect(() => bp.pack(-1, "varint")).toThrow();
});
});
describe("map", () => {
it("round-trips Map", () => {
const bp = createBitPacker();
const schema: BitPackerSchema = {
_map: { key: "-8", value: 255, maxSize: 10 },
};
const data = new Map([
["a", 1],
["b", 2],
]);
const unpacked = bp.unpack(bp.pack(data, schema), schema) as Map<
string,
number
>;
expect(Array.from(unpacked.entries())).toEqual([
["a", 1],
["b", 2],
]);
});
it("throws for non-Map data", () => {
const bp = createBitPacker();
const schema: BitPackerSchema = {
_map: { key: "-8", value: 255, maxSize: 10 },
};
expect(() => bp.pack({ a: 1 }, schema)).toThrow();
});
it("throws when map exceeds maxSize", () => {
const bp = createBitPacker();
const schema: BitPackerSchema = {
_map: { key: 255, value: 255, maxSize: 2 },
};
expect(() =>
bp.pack(
new Map([
[1, 1],
[2, 2],
[3, 3],
]),
schema
)
).toThrow();
});
it("packs map with maxSize 0 directly", () => {
const strategy = new MapPackingStrategy();
const writer = {
writeUint: () => {},
writeInt: () => {},
writeBool: () => {},
writeBytes: () => {},
};
const context = {
writer,
packValue: () => {},
} as unknown as PackingContext;
strategy.pack(
new Map(),
{ _map: { key: "-8", value: 255, maxSize: 0 } },
context
);
});
});
describe("set", () => {
it("round-trips Set", () => {
const bp = createBitPacker();
const schema: BitPackerSchema = {
_set: { element: 255, maxSize: 10 },
};
const data = new Set([1, 2, 3]);
const unpacked = bp.unpack(
bp.pack(data, schema),
schema
) as Set<number>;
expect(Array.from(unpacked)).toEqual([1, 2, 3]);
});
it("throws for non-Set data", () => {
const bp = createBitPacker();
const schema: BitPackerSchema = {
_set: { element: 255, maxSize: 10 },
};
expect(() => bp.pack([1, 2], schema)).toThrow();
});
it("throws when set exceeds maxSize", () => {
const bp = createBitPacker();
const schema: BitPackerSchema = {
_set: { element: 255, maxSize: 2 },
};
expect(() => bp.pack(new Set([1, 2, 3]), schema)).toThrow();
});
it("packs set with maxSize 0 directly", () => {
const strategy = new SetPackingStrategy();
const writer = {
writeUint: () => {},
writeInt: () => {},
writeBool: () => {},
writeBytes: () => {},
};
const context = {
writer,
packValue: () => {},
} as unknown as PackingContext;
strategy.pack(
new Set(),
{ _set: { element: 255, maxSize: 0 } },
context
);
});
});
// Adaptive blob `"~"` is now covered by the co-located
// `strategies/blob/blob.test.ts` per the bitpacker-canonical-spec.
describe("bitarray", () => {
it("round-trips variable-length bitArray", () => {
const bp = createBitPacker();
const data = [true, false, true];
expect(bp.unpack(bp.pack(data, "bitArray"), "bitArray")).toEqual(
data
);
});
it("round-trips fixed-length bitArray", () => {
const bp = createBitPacker();
const data = [true, false, true, false, true];
const schema = { _bitArray: { length: 5 } };
expect(bp.unpack(bp.pack(data, schema), schema)).toEqual(data);
});
it("throws for non-array data", () => {
const bp = createBitPacker();
expect(() => bp.pack("bad", "bitArray")).toThrow();
});
it("throws for non-boolean array", () => {
const bp = createBitPacker();
expect(() => bp.pack([1, 2], "bitArray")).toThrow();
});
it("throws for fixed length mismatch", () => {
const bp = createBitPacker();
const schema = { _bitArray: { length: 3 } };
expect(() => bp.pack([true, false], schema)).toThrow();
});
});
describe("bitset", () => {
it("round-trips bitset", () => {
const bp = createBitPacker();
const schema = { _bitset: { maxSize: 10 } };
const data = new Set([0, 2, 4]);
expect(bp.unpack(bp.pack(data, schema), schema)).toEqual(data);
});
it("throws for non-Set data", () => {
const bp = createBitPacker();
expect(() =>
bp.pack([0, 2], { _bitset: { maxSize: 10 } })
).toThrow();
});
it("throws when set exceeds maxSize", () => {
const bp = createBitPacker();
expect(() =>
bp.pack(new Set([0, 1, 2, 3]), { _bitset: { maxSize: 2 } })
).toThrow();
});
it("packs bitset with maxSize 0 directly", () => {
const strategy = new BitsetPackingStrategy();
const writer = {
writeUint: () => {},
writeInt: () => {},
writeBool: () => {},
writeBytes: () => {},
};
const context = { writer } as unknown as PackingContext;
strategy.pack(new Set(), { _bitset: { maxSize: 0 } }, context);
});
});
describe("datetime direct", () => {
function createMockContext() {
const calls: unknown[] = [];
const writer = {
writeInt: (v: number, b: number) =>
calls.push(["writeInt", v, b]),
writeUint: (v: number, b: number) =>
calls.push(["writeUint", v, b]),
writeBool: (v: boolean) => calls.push(["writeBool", v]),
writeBytes: (v: Uint8Array) => calls.push(["writeBytes", v]),
};
return { calls, writer };
}
it("packs timestamp with Date", () => {
const strategy = new DateTimePackingStrategy();
const { calls, writer } = createMockContext();
const date = new Date("2024-01-15T10:30:00.000Z");
strategy.pack(date, "timestamp", {
writer,
} as unknown as PackingContext);
expect(calls).toEqual([["writeInt", date.getTime(), 53]]);
});
it("packs timestamp with number", () => {
const strategy = new DateTimePackingStrategy();
const { calls, writer } = createMockContext();
strategy.pack(12345, "timestamp", {
writer,
} as unknown as PackingContext);
expect(calls).toEqual([["writeInt", 12345, 53]]);
});
it("packs timestamp-ms", () => {
const strategy = new DateTimePackingStrategy();
const { calls, writer } = createMockContext();
strategy.pack(12345, "timestamp-ms", {
writer,
} as unknown as PackingContext);
expect(calls).toEqual([["writeInt", 12345, 53]]);
});
it("packs date-only", () => {
const strategy = new DateTimePackingStrategy();
const { calls, writer } = createMockContext();
strategy.pack(12345, "date-only", {
writer,
} as unknown as PackingContext);
expect(calls).toEqual([["writeInt", 12345, 53]]);
});
it("packs timestamp-s", () => {
const strategy = new DateTimePackingStrategy();
const { calls, writer } = createMockContext();
strategy.pack(5000, "timestamp-s", {
writer,
} as unknown as PackingContext);
expect(calls).toEqual([["writeInt", 5, 32]]);
});
it("packs timespan", () => {
const strategy = new DateTimePackingStrategy();
const { calls, writer } = createMockContext();
strategy.pack(3600, "timespan", {
writer,
} as unknown as PackingContext);
expect(calls).toEqual([["writeInt", 3600, 53]]);
});
it("packs timespan-ms", () => {
const strategy = new DateTimePackingStrategy();
const { calls, writer } = createMockContext();
strategy.pack(3600, "timespan-ms", {
writer,
} as unknown as PackingContext);
expect(calls).toEqual([["writeInt", 3600, 53]]);
});
it("throws for invalid data type", () => {
const strategy = new DateTimePackingStrategy();
const { writer } = createMockContext();
expect(() =>
strategy.pack("bad", "timestamp", {
writer,
} as unknown as PackingContext)
).toThrow();
});
it("unpacks timestamp", () => {
const strategy = new DateTimePackingStrategy();
const reader = { readInt: () => 12345 };
expect(
strategy.unpack("timestamp", {
reader,
} as unknown as PackingContext)
).toBe(12345);
});
it("unpacks timestamp-ms", () => {
const strategy = new DateTimePackingStrategy();
const reader = { readInt: () => 12345 };
expect(
strategy.unpack("timestamp-ms", {
reader,
} as unknown as PackingContext)
).toBe(12345);
});
it("unpacks date-only", () => {
const strategy = new DateTimePackingStrategy();
const reader = { readInt: () => 12345 };
expect(
strategy.unpack("date-only", {
reader,
} as unknown as PackingContext)
).toBe(12345);
});
it("unpacks timestamp-s", () => {
const strategy = new DateTimePackingStrategy();
const reader = { readInt: () => 5 };
expect(
strategy.unpack("timestamp-s", {
reader,
} as unknown as PackingContext)
).toBe(5000);
});
it("unpacks timespan", () => {
const strategy = new DateTimePackingStrategy();
const reader = { readInt: () => 3600 };
expect(
strategy.unpack("timespan", {
reader,
} as unknown as PackingContext)
).toBe(3600);
});
it("unpacks timespan-ms", () => {
const strategy = new DateTimePackingStrategy();
const reader = { readInt: () => 3600 };
expect(
strategy.unpack("timespan-ms", {
reader,
} as unknown as PackingContext)
).toBe(3600);
});
it("unpack returns 0 for unknown token", () => {
const strategy = new DateTimePackingStrategy();
const reader = { readInt: () => 0 };
expect(
strategy.unpack(
"unknown" as unknown as BitPackerSchema,
{
reader,
} as unknown as PackingContext
)
).toBe(0);
});
});
describe("defs", () => {
it("round-trips via schema registry", () => {
const bp = new BitPacker({
schemaRegistry: new Map([
["player", { name: "-8", health: 255 }],
]),
});
registerDefaultStrategies(bp.strategyManager);
const schema = { _defs: { name: "player" } };
const data = { name: "Alice", health: 100 };
expect(bp.unpack(bp.pack(data, schema), schema)).toEqual(data);
});
});
describe("version", () => {
it("round-trips versioned schema", () => {
const bp = createBitPacker();
const schema = { _version: 1, _schema: 255 };
expect(bp.unpack(bp.pack(42, schema), schema)).toBe(42);
});
it("throws on strict mismatch", () => {
const bp = createBitPacker();
const schema = { _version: 1, _schema: 255 };
const packed = bp.pack(42, schema);
const badSchema = { _version: 2, _schema: 255 };
expect(() => bp.unpack(packed, badSchema)).toThrow();
});
it("accepts a listed version when _tolerance: forward", () => {
const bp = createBitPacker();
const writeSchema = { _version: 1, _schema: 255 };
const packed = bp.pack(42, writeSchema);
const readSchema = {
_version: 2,
_schema: 255,
_tolerance: "forward" as const,
_allowedVersions: [1, 2, 3],
};
expect(bp.unpack(packed, readSchema)).toBe(42);
});
it("accepts any version when _tolerance: forward without allowlist", () => {
const bp = createBitPacker();
const writeSchema = { _version: 7, _schema: 255 };
const packed = bp.pack(42, writeSchema);
const readSchema = {
_version: 1,
_schema: 255,
_tolerance: "forward" as const,
};
expect(bp.unpack(packed, readSchema)).toBe(42);
});
it("rejects an unlisted version under _tolerance: forward", () => {
const bp = createBitPacker();
const writeSchema = { _version: 9, _schema: 255 };
const packed = bp.pack(42, writeSchema);
const readSchema = {
_version: 1,
_schema: 255,
_tolerance: "forward" as const,
_allowedVersions: [1, 2, 3],
};
expect(() => bp.unpack(packed, readSchema)).toThrow();
});
});
describe("conditional (_when / _if)", () => {
it("selects branch based on parent data with _when", () => {
const bp = createBitPacker();
const schema: BitPackerSchema = {
active: true,
payload: {
_when: { field: "active", op: "==", value: true },
_then: { level: 255 },
_else: { name: "-8" },
},
};
const activeData = { active: true, payload: { level: 5 } };
const inactiveData = { active: false, payload: { name: "idle" } };
expect(bp.unpack(bp.pack(activeData, schema), schema)).toEqual(
activeData
);
expect(bp.unpack(bp.pack(inactiveData, schema), schema)).toEqual(
inactiveData
);
});
it("selects branch based on _if predicate", () => {
const bp = createBitPacker();
const schema: BitPackerSchema = {
active: true,
payload: {
_if: { field: "active", op: "==", value: true },
_then: { level: 255 },
_else: { name: "-8" },
},
};
const activeData = { active: true, payload: { level: 5 } };
const inactiveData = { active: false, payload: { name: "idle" } };
expect(bp.unpack(bp.pack(activeData, schema), schema)).toEqual(
activeData
);
expect(bp.unpack(bp.pack(inactiveData, schema), schema)).toEqual(
inactiveData
);
});
it("handles missing _else by returning undefined", () => {
const bp = createBitPacker();
const schema = {
active: true,
payload: {
_if: { field: "active", op: "==", value: true },
_then: { level: 255 },
},
};
const inactive = { active: false, payload: undefined };
expect(bp.unpack(bp.pack(inactive, schema), schema)).toEqual({
active: false,
payload: undefined,
});
});
it("uses != op", () => {
const bp = createBitPacker();
const schema = {
mode: "-8",
payload: {
_when: { field: "mode", op: "!=", value: "A" },
_then: { level: 255 },
},
};
const data = { mode: "B", payload: { level: 10 } };
expect(bp.unpack(bp.pack(data, schema), schema)).toEqual(data);
});
it("uses < op", () => {
const bp = createBitPacker();
const schema = {
level: 255,
bonus: {
_when: { field: "level", op: "<", value: 100 },
_then: 255,
},
};
const data = { level: 50, bonus: 10 };
expect(bp.unpack(bp.pack(data, schema), schema)).toEqual(data);
});
it("uses <= op", () => {
const bp = createBitPacker();
const schema = {
level: 255,
bonus: {
_when: { field: "level", op: "<=", value: 50 },
_then: 255,
},
};
const data = { level: 50, bonus: 10 };
expect(bp.unpack(bp.pack(data, schema), schema)).toEqual(data);
});
it("uses > op", () => {
const bp = createBitPacker();
const schema = {
level: 255,
bonus: {
_when: { field: "level", op: ">", value: 50 },
_then: 255,
},
};
const data = { level: 100, bonus: 10 };
expect(bp.unpack(bp.pack(data, schema), schema)).toEqual(data);
});
it("uses >= op", () => {
const bp = createBitPacker();
const schema = {
level: 255,
bonus: {
_when: { field: "level", op: ">=", value: 100 },
_then: 255,
},
};
const data = { level: 100, bonus: 10 };
expect(bp.unpack(bp.pack(data, schema), schema)).toEqual(data);
});
it("uses in op", () => {
const bp = createBitPacker();
const schema = {
mode: "-8",
bonus: {
_when: { field: "mode", op: "in", value: ["A", "B"] },
_then: 255,
},
};
const data = { mode: "B", bonus: 10 };
expect(bp.unpack(bp.pack(data, schema), schema)).toEqual(data);
});
it("uses has op", () => {
const bp = createBitPacker();
const schema = {
tags: [3, "-8"],
bonus: {
_when: { field: "tags", op: "has", value: "vip" },
_then: 255,
},
};
const data = { tags: ["vip", "pro"], bonus: 10 };
expect(bp.unpack(bp.pack(data, schema), schema)).toEqual(data);
});
});
describe("conditional direct", () => {
it("isConditionalSchema returns false for invalid predicates", () => {
expect(
isConditionalSchema({
_if: { field: "", op: "==", value: 1 },
_then: 255,
})
).toBe(false);
expect(
isConditionalSchema({
_if: { field: "x", op: "??", value: 1 },
_then: 255,
})
).toBe(false);
expect(
isConditionalSchema({
_if: { field: "x", op: "==" },
_then: 255,
})
).toBe(false);
expect(isConditionalSchema({ _then: 255 })).toBe(false);
expect(isConditionalSchema("bad")).toBe(false);
});
it("evaluatePredicate handles all ops", () => {
expect(evaluatePredicate({ x: 5 }, "x", "==", 5)).toBe(true);
expect(evaluatePredicate({ x: 5 }, "x", "!=", 5)).toBe(false);
expect(evaluatePredicate({ x: 5 }, "x", "<", 10)).toBe(true);
expect(evaluatePredicate({ x: 5 }, "x", "<=", 5)).toBe(true);
expect(evaluatePredicate({ x: 5 }, "x", ">", 1)).toBe(true);
expect(evaluatePredicate({ x: 5 }, "x", ">=", 5)).toBe(true);
expect(evaluatePredicate({ x: "a" }, "x", "in", ["a", "b"])).toBe(
true
);
expect(evaluatePredicate({ x: ["a", "b"] }, "x", "has", "a")).toBe(
true
);
});
it("evaluatePredicate handles null context", () => {
expect(evaluatePredicate(null, "x", "==", 1)).toBe(false);
});
it("evaluatePredicate handles non-array in", () => {
expect(evaluatePredicate({ x: "a" }, "x", "in", "not-array")).toBe(
false
);
});
it("evaluatePredicate handles non-array has", () => {
expect(evaluatePredicate({ x: "a" }, "x", "has", "a")).toBe(false);
});
it("resolveBranch returns NO_BRANCH when predicate missing", () => {
const strategy = new ConditionalPackingStrategy();
const result = (strategy as any).resolveBranch(
{ _then: 255 },
{} as unknown as PackingContext,
{}
);
expect(result).toBeDefined();
// NO_BRANCH is a unique symbol, so we just check it's not a schema
expect(typeof result).toBe("symbol");
});
it("resolveBranch uses data when parentData is undefined", () => {
const strategy = new ConditionalPackingStrategy();
const result = (strategy as any).resolveBranch(
{
_if: { field: "x", op: "==", value: 1 },
_then: 255,
},
{} as unknown as PackingContext,
{ x: 1 }
);
expect(result).toBe(255);
});
});
describe("union", () => {
it("round-trips discriminated union", () => {
const bp = createBitPacker();
const schema: BitPackerSchema = {
_union: {
on: "kind",
cases: {
move: { kind: "-8", x: 255, y: 255 },
chat: { kind: "-8", text: "-16" },
},
},
};
const moveData = { kind: "move", x: 10, y: 20 };
const chatData = { kind: "chat", text: "hello" };
expect(bp.unpack(bp.pack(moveData, schema), schema)).toEqual(
moveData
);
expect(bp.unpack(bp.pack(chatData, schema), schema)).toEqual(
chatData
);
});
it("throws for invalid discriminator", () => {
const bp = createBitPacker();
const schema: BitPackerSchema = {
_union: {
on: "kind",
cases: {
move: { kind: "-8", x: 255 },
},
},
};
expect(() => bp.pack({ kind: "chat" }, schema)).toThrow();
});
it("throws on unpack when case index out of range", () => {
const strategy = new UnionPackingStrategy();
const reader = { readUint: () => 5 };
const context = {
reader,
unpackValue: () => {},
} as unknown as PackingContext;
const schema = {
_union: {
on: "kind",
cases: { a: { kind: "-8" } },
},
};
expect(() => strategy.unpack(schema, context)).toThrow();
});
it("isUnionSchema rejects non-objects", async () => {
const { isUnionSchema } = await import(
"../strategies/union/union.js"
);
expect(isUnionSchema("bad")).toBe(false);
});
it("isUnionSchema rejects invalid cases", async () => {
const { isUnionSchema } = await import(
"../strategies/union/union.js"
);
expect(isUnionSchema({ _union: { on: "kind" } })).toBe(false);
expect(isUnionSchema({ _union: { on: "kind", cases: [] } })).toBe(
false
);
expect(isUnionSchema({ _union: { on: "kind", cases: {} } })).toBe(
false
);
});
it("throws when union case schema is undefined", () => {
const bp = createBitPacker();
const schema = {
_union: {
on: "kind",
cases: { a: undefined },
},
} as unknown as BitPackerSchema;
expect(() => bp.pack({ kind: "a" }, schema)).toThrow();
});
it("throws on unpack when union case schema is undefined", () => {
const strategy = new UnionPackingStrategy();
const reader = { readUint: () => 0 };
const context = {
reader,
unpackValue: () => {},
} as unknown as PackingContext;
const schema = {
_union: {
on: "kind",
cases: { a: undefined },
},
};
expect(() => strategy.unpack(schema, context)).toThrow();
});
it("isUnionSchema rejects non-string on", async () => {
const { isUnionSchema } = await import(
"../strategies/union/union.js"
);
expect(
isUnionSchema({ _union: { on: 123, cases: { a: 255 } } })
).toBe(false);
});
it("throws for non-object data in union", () => {
const strategy = new UnionPackingStrategy();
const schema = {
_union: { on: "kind", cases: { a: 255 } },
} as unknown as BitPackerSchema;
expect(() =>
strategy.pack("bad", schema, {} as unknown as PackingContext)
).toThrow();
});
});
describe("complex nested", () => {
it("round-trips game state-like object", () => {
const bp = createBitPacker();
const schema = {
id: "uuid",
health: 255,
pos: { x: 1023, y: 1023 },
tags: [4, "-8"],
weapon: {
_optional: {
name: "-16",
damage: 255,
},
},
};
const data = {
id: "550e8400-e29b-41d4-a716-446655440000",
health: 100,
pos: { x: 500, y: 250 },
tags: ["fast", "rare"],
weapon: {
name: "sword",
damage: 50,
},
};
const packed = bp.pack(data, schema);
const unpacked = bp.unpack(packed, schema);
expect(unpacked).toEqual(data);
});
});
describe("JSON purity", () => {
it("round-trips every example schema through JSON.stringify/parse", () => {
const schemas: BitPackerSchema[] = [
255,
-127,
"8",
"-16",
"uuid",
"f32",
"f64",
true,
null,
"bigint-i64",
"bigint-u64",
{ x: 255, y: "-16", flag: true },
[255],
[10, 255],
{ _enum: ["red", "green", "blue"] },
{ _optional: 255 },
{ _oneOf: ["-8", 255] },
{
_if: { field: "active", op: "==", value: true },
_then: { level: 255 },
},
{
_when: { field: "active", op: "==", value: true },
_then: { level: 255 },
},
{ _customType: "date" },
{
_union: {
on: "kind",
cases: {
move: { kind: "-8", x: 255 },
chat: { kind: "-8", text: "-16" },
},
},
},
{ _blob: { maxLength: 100 } },
{ _map: { key: "-8", value: 255, maxSize: 10 } },
{ _set: { element: 255, maxSize: 10 } },
{ _ref: "player" },
];
for (const schema of schemas) {
const reparsed = JSON.parse(JSON.stringify(schema));
expect(reparsed).toEqual(schema);
}
});
});
describe("_customType", () => {
it("round-trips via CustomTypeRegistry", () => {
const handler: CustomTypeHandler<number> = {
typeName: "test-int",
pack(value) {
const buf = new ArrayBuffer(4);
new DataView(buf).setInt32(0, value, true);
return new Uint8Array(buf);
},
unpack(data) {
return new DataView(
data.buffer,
data.byteOffset,
data.byteLength
).getInt32(0, true);
},
validate(value): boolean {
return typeof value === "number";
},
getSchema() {
return { _customType: "test-int" };
},
getSize() {
return 4;
},
};
CustomTypeRegistry.getInstance().register(handler);
const bp = createBitPacker();
const schema: BitPackerSchema = { _customType: "test-int" };
const packed = bp.pack(12345, schema);
expect(bp.unpack(packed, schema)).toBe(12345);
});
it("round-trips via _bp wrapper", () => {
const bp = createBitPacker();
const schema = { _bp: { schema: 255 } };
expect(bp.unpack(bp.pack(42, schema), schema)).toBe(42);
});
it("throws for unregistered custom type on pack", () => {
const bp = createBitPacker();
const schema = { _customType: "definitely-missing-handler" };
expect(() => bp.pack(42, schema)).toThrow();
});
it("throws for unregistered custom type on unpack", () => {
const bp = createBitPacker();
const schema = { _customType: "definitely-missing-handler-2" };
// We need some packed data to unpack; use a valid pack then swap schema
const packed = bp.pack(42, 255);
expect(() => bp.unpack(packed, schema)).toThrow();
});
});
});