cultcache-ts slots: a default fills only a missing slot, a nil is null in a reference slot and refused in a value slot, bytes reads a str
Held to tests/vectors/slot-nil/reads.msgpack, written by the C# reference (SlotNilVectorTests): every accepted row rewrites to the reference's bytes, nil in an int slot is refused. 94a26431c000c0 round-trips byte-identical. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
6d410f9caf
commit
c85edf90bb
3 changed files with 75 additions and 12 deletions
|
|
@ -49,10 +49,20 @@ export const boolean: Codec<boolean> = {
|
|||
decode: (raw) => (expect(raw, "a boolean", typeof raw === "boolean"), raw as boolean),
|
||||
};
|
||||
|
||||
// The reference's byte[] reads a MessagePack str as its bytes and writes bin (an old-spec writer's output), so a str is
|
||||
// accepted and re-encodes as bin. The wire reader has already decoded the str, so a str that held invalid UTF-8 (it
|
||||
// reads back U+FFFD) is refused rather than turned into other bytes.
|
||||
export const bytes: Codec<Uint8Array> = {
|
||||
typeName: "System.Byte[]",
|
||||
encode: (value) => (expect(value, "bytes", value instanceof Uint8Array), value),
|
||||
decode: (raw) => (expect(raw, "bytes", raw instanceof Uint8Array), raw as Uint8Array),
|
||||
decode(raw) {
|
||||
if (typeof raw === "string") {
|
||||
if (raw.includes("<22>")) refuse("bytes", "a string that is not valid UTF-8");
|
||||
return new TextEncoder().encode(raw);
|
||||
}
|
||||
expect(raw, "bytes", raw instanceof Uint8Array);
|
||||
return raw as Uint8Array;
|
||||
},
|
||||
};
|
||||
|
||||
// A C# integer slot: written as the smallest MessagePack integer and read from any integer encoding in range. A float is
|
||||
|
|
@ -222,8 +232,10 @@ function pascalCase(name: string): string {
|
|||
}
|
||||
|
||||
// A positional record: slot `key` holds field `name`. Gaps are written nil and skipped on read, extra trailing slots
|
||||
// are skipped, and a slot missing from the payload takes its declared default or is refused. A nil in a slot that
|
||||
// declares a default also takes the default (cultcache-py's rule).
|
||||
// are skipped, and a slot missing from the payload takes its declared default or is refused. A default fills only a
|
||||
// missing trailing slot, never a nil: as in the reference, a nil in a reference slot (string, bytes, record, union,
|
||||
// list) reads as null and writes back as nil, so a record the reference wrote re-encodes byte-identical, and a nil in
|
||||
// a value slot (int, float, bool) is refused unless the slot is nullable.
|
||||
export function slots<TValue extends object>(spec: SlotsSpec<TValue>, typeName?: string): SlotsCodec<TValue> {
|
||||
const keys = slotKeys(spec, "A slot record", 0, MAX_SLOT_KEY);
|
||||
if (keys.length === 0) {
|
||||
|
|
@ -259,8 +271,10 @@ export function slots<TValue extends object>(spec: SlotsSpec<TValue>, typeName?:
|
|||
expect(value, "a record", typeof value === "object" && value !== null);
|
||||
const out: unknown[] = new Array(length).fill(null);
|
||||
for (const field of fields) {
|
||||
out[field.key] = at(`${field.name} (slot ${field.key})`, () =>
|
||||
field.codec.encode((value as Record<string, unknown>)[field.name]));
|
||||
const member = (value as Record<string, unknown>)[field.name];
|
||||
out[field.key] = member === null && !field.codec.valueType
|
||||
? null
|
||||
: at(`${field.name} (slot ${field.key})`, () => field.codec.encode(member));
|
||||
}
|
||||
return out;
|
||||
},
|
||||
|
|
@ -269,13 +283,15 @@ export function slots<TValue extends object>(spec: SlotsSpec<TValue>, typeName?:
|
|||
const input = raw as unknown[];
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const field of fields) {
|
||||
const present = field.key < input.length && !(input[field.key] === null && field.hasDefault);
|
||||
const present = field.key < input.length;
|
||||
if (!present && !field.hasDefault) {
|
||||
throw new Error(`${field.name} (slot ${field.key}): the slot is missing and declares no default.`);
|
||||
}
|
||||
out[field.name] = present
|
||||
? at(`${field.name} (slot ${field.key})`, () => field.codec.decode(input[field.key]))
|
||||
: defaultOf(field);
|
||||
out[field.name] = !present
|
||||
? defaultOf(field)
|
||||
: input[field.key] === null && !field.codec.valueType
|
||||
? null
|
||||
: at(`${field.name} (slot ${field.key})`, () => field.codec.decode(input[field.key]));
|
||||
}
|
||||
return out as TValue;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -290,16 +290,20 @@ test("slots: a gap writes nil, a missing slot reads its default, one without a d
|
|||
const record = slots<{ a: string; c: string; d: number }>({ 0: ["a", string], 2: ["c", string], 4: ["d", int32, 9] });
|
||||
assert.deepEqual(record.encode({ a: "x", c: "y", d: 1 }), ["x", null, "y", null, 1]);
|
||||
assert.deepEqual(record.decode(["x", null, "y"]), { a: "x", c: "y", d: 9 });
|
||||
assert.deepEqual(record.decode(["x", 5, "y", null, null]), { a: "x", c: "y", d: 9 });
|
||||
assert.deepEqual(record.decode(["x", 5, "y", null]), { a: "x", c: "y", d: 9 });
|
||||
assert.throws(() => record.decode(["x", 5, "y", null, null]), { message: "d (slot 4): expected an int32, found nil" });
|
||||
assert.throws(() => record.decode(["x"]), /c \(slot 2\): the slot is missing and declares no default/u);
|
||||
assert.deepEqual(record.decode(["x", null, "y", null, 1, "extra", "slots"]), { a: "x", c: "y", d: 1 });
|
||||
assert.throws(() => record.decode({ a: "x" }), /expected an array of slots/u);
|
||||
});
|
||||
|
||||
test("slots: a default may be a function, and nil in a defaulted slot takes it", () => {
|
||||
test("slots: a default may be a function and fills only a missing slot, never a nil", () => {
|
||||
const record = slots<{ tags: string[] }>({ 0: ["tags", list(string), () => ["fresh"]] });
|
||||
assert.deepEqual(record.decode([]), { tags: ["fresh"] });
|
||||
assert.deepEqual(record.decode([null]), { tags: ["fresh"] });
|
||||
assert.deepEqual(record.decode([null]), { tags: null });
|
||||
const counted = slots<{ n: number }>({ 0: ["n", int32, 4] });
|
||||
assert.deepEqual(counted.decode([]), { n: 4 });
|
||||
assert.throws(() => counted.decode([null]), { message: "n (slot 0): expected an int32, found nil" });
|
||||
});
|
||||
|
||||
test("a slot refusal names the field and slot and never the value", () => {
|
||||
|
|
@ -740,3 +744,46 @@ test("the mixed-record vector is the bytes the reference writes for a record of
|
|||
assert.equal(hex(writeWire(mixed.encode({ id: "n1", i: 7, l: 8, f: 1.5, d: 2.5 }))), hex(committed));
|
||||
assert.deepEqual(mixed.decode(readWire(committed)), { id: "n1", i: 7, l: 8, f: 1.5, d: 2.5 });
|
||||
});
|
||||
|
||||
// tests/vectors/slot-nil is written by the C# reference (SlotNilVectorTests): what it reads and rewrites for a nil, a str in a
|
||||
// byte[] slot, and a record short of its last slots. A runtime accepts what it accepts and rewrites the same bytes.
|
||||
test("slots read and rewrite exactly what the C# reference does for nil, a str in bytes, and short records", async () => {
|
||||
const inner = slots<{ x: string }>({ 0: ["x", string, ""] });
|
||||
const probe = slots<{ s: string; i: number; b: Uint8Array; r: { x: string }; l: string[] }>({
|
||||
0: ["s", string, ""],
|
||||
1: ["i", int32, 0],
|
||||
2: ["b", bytes, () => new Uint8Array()],
|
||||
3: ["r", inner, null as unknown as { x: string }],
|
||||
4: ["l", list(string), () => []],
|
||||
});
|
||||
const rows = readWire(new Uint8Array(await readFile(join(vectors, "..", "slot-nil", "reads.msgpack")))) as [string, Uint8Array, boolean, Uint8Array][];
|
||||
assert.equal(rows.length, 14);
|
||||
let accepted = 0;
|
||||
for (const [name, payload, wasAccepted, rewritten] of rows) {
|
||||
if (wasAccepted) {
|
||||
assert.equal(hex(writeWire(probe.encode(probe.decode(readWire(payload))))), hex(rewritten), name);
|
||||
accepted += 1;
|
||||
} else {
|
||||
assert.throws(() => probe.decode(readWire(payload)), (error: Error) => !error.message.includes(hex(payload).slice(2)), name);
|
||||
}
|
||||
}
|
||||
assert.equal(accepted, 12);
|
||||
});
|
||||
|
||||
test("a nil reads as null and writes as nil in every reference slot, and a value slot refuses it", () => {
|
||||
const record = slots<{ s: string; b: Uint8Array; i: number }>({ 0: ["s", string], 1: ["b", bytes], 2: ["i", int32, 5] });
|
||||
const read = record.decode([null, null, 3]);
|
||||
assert.deepEqual(read, { s: null, b: null, i: 3 });
|
||||
assert.deepEqual(record.encode(read), [null, null, 3]);
|
||||
assert.throws(() => record.encode({ s: "a", b: new Uint8Array(), i: null as unknown as number }), { message: "i (slot 2): expected an int32, found nil" });
|
||||
assert.throws(() => record.decode(["a", new Uint8Array(), null]), { message: "i (slot 2): expected an int32, found nil" });
|
||||
});
|
||||
|
||||
test("a bytes slot reads a MessagePack str as its bytes and writes bin, and refuses a str that was not valid UTF-8", () => {
|
||||
const record = slots<{ b: Uint8Array }>({ 0: ["b", bytes] });
|
||||
const read = record.decode(readWire(fromHex("91a3616263")));
|
||||
assert.equal(hex(read.b), "616263");
|
||||
assert.equal(hex(writeWire(record.encode(read))), "91c403616263");
|
||||
assert.throws(() => record.decode(readWire(fromHex("91a2c328"))), { message: "b (slot 0): expected bytes, found a string that is not valid UTF-8" });
|
||||
assert.throws(() => record.decode([5]), { message: "b (slot 0): expected bytes, found number" });
|
||||
});
|
||||
|
|
|
|||
BIN
tests/vectors/slot-nil/reads.msgpack
Normal file
BIN
tests/vectors/slot-nil/reads.msgpack
Normal file
Binary file not shown.
Loading…
Reference in a new issue