|
| 1 | +import { describe, it, expect } from "vitest"; |
| 2 | +import { TupleItem } from "./index.js"; |
| 3 | +import * as tuple from "./index.js"; |
| 4 | + |
| 5 | +// Helper functions |
| 6 | +function hexToBytes(hex: string): Uint8Array { |
| 7 | + const bytes = new Uint8Array(hex.length / 2); |
| 8 | + for (let i = 0; i < bytes.length; i++) { |
| 9 | + bytes[i] = parseInt(hex.substring(i * 2, i * 2 + 2), 16); |
| 10 | + } |
| 11 | + return bytes; |
| 12 | +} |
| 13 | + |
| 14 | +function asciiToBytes(str: string): Uint8Array { |
| 15 | + const bytes = new Uint8Array(str.length); |
| 16 | + for (let i = 0; i < str.length; i++) { |
| 17 | + bytes[i] = str.charCodeAt(i) & 0xff; |
| 18 | + } |
| 19 | + return bytes; |
| 20 | +} |
| 21 | + |
| 22 | +function bytesEqual(a: Uint8Array, b: Uint8Array): boolean { |
| 23 | + if (a.length !== b.length) return false; |
| 24 | + for (let i = 0; i < a.length; i++) { |
| 25 | + if (a[i] !== b[i]) return false; |
| 26 | + } |
| 27 | + return true; |
| 28 | +} |
| 29 | + |
| 30 | +const floatBytes = (x: number): Uint8Array => { |
| 31 | + const result = new Uint8Array(4); |
| 32 | + const view = new DataView(result.buffer); |
| 33 | + view.setFloat32(0, x, false); |
| 34 | + return result; |
| 35 | +}; |
| 36 | + |
| 37 | +describe("tuple", () => { |
| 38 | + const assertRoundTripBytes = (orig: Uint8Array, strict: boolean = false) => { |
| 39 | + const val = tuple.unpack(orig, strict)[0] as TupleItem; |
| 40 | + const packed = tuple.pack([val]); |
| 41 | + expect(bytesEqual(packed, orig)).toBe(true); |
| 42 | + }; |
| 43 | + |
| 44 | + const assertEncodesAs = ( |
| 45 | + value: TupleItem, |
| 46 | + data: Uint8Array | string | number[] |
| 47 | + ) => { |
| 48 | + const encoded = tuple.pack([value]); |
| 49 | + let bytes: Uint8Array; |
| 50 | + if (data instanceof Uint8Array) { |
| 51 | + bytes = data; |
| 52 | + } else if (typeof data === "string") { |
| 53 | + bytes = asciiToBytes(data); |
| 54 | + } else { |
| 55 | + bytes = new Uint8Array(data); |
| 56 | + } |
| 57 | + expect(bytesEqual(encoded, bytes)).toBe(true); |
| 58 | + |
| 59 | + // Check that numbered int -> bigint has no effect on encoded output. |
| 60 | + if (typeof value === "number" && Number.isInteger(value)) { |
| 61 | + const encoded2 = tuple.pack([BigInt(value)]); |
| 62 | + expect(bytesEqual(encoded2, bytes)).toBe(true); |
| 63 | + } |
| 64 | + |
| 65 | + const decoded = tuple.unpack(encoded); |
| 66 | + // Handle NaN case |
| 67 | + if (typeof value === "number" && isNaN(value as number)) { |
| 68 | + expect(isNaN(decoded[0] as number)).toBe(true); |
| 69 | + } else { |
| 70 | + expect(decoded).toEqual([value]); |
| 71 | + } |
| 72 | + }; |
| 73 | + |
| 74 | + describe("roundtrips expected values", () => { |
| 75 | + const assertRoundTrip = (val: TupleItem, strict: boolean = false) => |
| 76 | + it(typeof val === "bigint" ? `${val}n` : JSON.stringify(val), () => { |
| 77 | + const packed = tuple.pack([val]); |
| 78 | + if (!Array.isArray(val)) { |
| 79 | + const packedRaw = tuple.pack(val); |
| 80 | + expect(bytesEqual(packed, packedRaw)).toBe(true); |
| 81 | + } |
| 82 | + |
| 83 | + const unpacked = tuple.unpack(packed, strict)[0]; |
| 84 | + expect(unpacked).toEqual(val); |
| 85 | + |
| 86 | + // Check that numbered int -> bigint has no effect on encoded output. |
| 87 | + if (typeof val === "number" && Number.isSafeInteger(val)) { |
| 88 | + const packed2 = tuple.pack([BigInt(val)]); |
| 89 | + expect(bytesEqual(packed2, packed)).toBe(true); |
| 90 | + } |
| 91 | + }); |
| 92 | + |
| 93 | + const data = ["hi", null, "\u{1F47E}", 321, 0, -100]; |
| 94 | + assertRoundTrip(data); |
| 95 | + |
| 96 | + assertRoundTrip(0.75); |
| 97 | + assertRoundTrip(BigInt("12341234123412341234")); |
| 98 | + assertRoundTrip(BigInt("-12341234123412341234")); |
| 99 | + // This is a 230-byte number. |
| 100 | + assertRoundTrip( |
| 101 | + BigInt( |
| 102 | + "-25590177972831273370257770873989184378968622566250081269954042898433662370031719667203350964691861270342483438771650473254888465056727193237251538125410415669915254450681177531095961100324827856201637570233530243472179104778438087224216101130117197440338289639344554123323256917100155477022101041486896307697849188764471017736123757252639610971416446462793453318430443692386602432742205109597722547740645132051736817382305630442267915356539282325495631800602625500347541220888685499019574296692446821077084703673346449833537043377925515781584311410015431" |
| 103 | + ) |
| 104 | + ); |
| 105 | + |
| 106 | + assertRoundTrip(Number.MAX_SAFE_INTEGER); |
| 107 | + assertRoundTrip(Number.MAX_SAFE_INTEGER + 1); // Encoded as a double. |
| 108 | + assertRoundTrip(BigInt(Number.MAX_SAFE_INTEGER) + BigInt(1)); // Encoded as an integer |
| 109 | + |
| 110 | + assertRoundTrip(-Number.MAX_SAFE_INTEGER); |
| 111 | + assertRoundTrip(-Number.MAX_SAFE_INTEGER - 1); |
| 112 | + assertRoundTrip(-BigInt(Number.MAX_SAFE_INTEGER) - BigInt(1)); |
| 113 | + |
| 114 | + assertRoundTrip( |
| 115 | + { type: "float", value: 0.5, rawEncoding: floatBytes(0.5) }, |
| 116 | + true |
| 117 | + ); |
| 118 | + }); |
| 119 | + |
| 120 | + it("handles negative bigint numbers correctly", () => { |
| 121 | + // regression |
| 122 | + const bytes = asciiToBytes( |
| 123 | + "\x0b\x19\xac\x9b\xcdf;\xe9\x08\xa9\xfc\x17\x06hE^4}" + |
| 124 | + "\xc7\xbc\xfe\xa9\x9997\x90\xdb\xdf\tD\xc4\xd1\xfc\xdc\x934\xf5\xa8\xe23>E" + |
| 125 | + "\x94\xf8\xd2)\x1fz\x94QO\xf0\x01\xe6pza,\x81\x05us=\xd6\xc7\xa3\xaa\xd1R" + |
| 126 | + "\xf8j\xdd\xb0\xaa\x03lW\xb7\xd3\xf5\x84\x7ff\xbb\xb31\xc8\xcf\xb7gpg\x18" + |
| 127 | + "\x11\xa7\x9b6\xaa\xd7\xe3\x82\x8dM\xf7\xf3\xda\xe8\xac\xeb\xb8\xfd\xce\xae" + |
| 128 | + "\xf2[Z\r?\xd7@\x03\xf8c\xdb\xd6q\xac\xfe\xfe\xcb\xfa\x17\xde\x08\xb9\xe5K" + |
| 129 | + "\x81\xad\xdf\xe7\xd9\x10\x12\xb0L\xa1\x15c\x0e\xc3\xda\xd2;\xbc.\xcdo\xc9" + |
| 130 | + "1/-\xf6\xf1\xfajW\xd1\xa5\xaa\xbf\xda\xfel\xde\x84\xe9~\xc5\xe7\xe7}\xe1" + |
| 131 | + "\x96\xc3\x91\xaf\x8eQQz\xd4\xff\x94i\xc3\xe8\xc0\xb3\xa0\"\xd6\xe26\xee\x0b" + |
| 132 | + '\x05&\x9d\x95\x19)q}\x02$4\xa8\xf61\x07i\x89\xa3&\x82\x89\xaf=\x17C8' |
| 133 | + ); |
| 134 | + |
| 135 | + const expected = BigInt( |
| 136 | + "-25590177972831273370257770873989184378968622566250081" + |
| 137 | + "26995404289843366237003171966720335096469186127034248343877165047325488846" + |
| 138 | + "50567271932372515381254104156699152544506811775310959611003248278562016375" + |
| 139 | + "70233530243472179104778438087224216101130117197440338289639344554123323256" + |
| 140 | + "91710015547702210104148689630769784918876447101773612375725263961097141644" + |
| 141 | + "64627934533184304436923866024327422051095977225477406451320517368173823056" + |
| 142 | + "30442267915356539282325495631800602625500347541220888685499019574296692446" + |
| 143 | + "821077084703673346449833537043377925515781584311410015431" |
| 144 | + ); |
| 145 | + |
| 146 | + assertEncodesAs(expected, bytes); |
| 147 | + }); |
| 148 | + |
| 149 | + it("handles null and undefined as expected", () => { |
| 150 | + expect(bytesEqual(tuple.pack(null), new Uint8Array([0]))).toBe(true); |
| 151 | + expect(bytesEqual(tuple.pack(undefined), new Uint8Array([]))).toBe(true); |
| 152 | + expect(bytesEqual(tuple.pack([]), new Uint8Array([]))).toBe(true); |
| 153 | + |
| 154 | + expect(() => { |
| 155 | + tuple.pack([undefined as unknown as TupleItem]); |
| 156 | + }).toThrow(); |
| 157 | + }); |
| 158 | + |
| 159 | + it("implements bigint encoding in a way that matches the java bindings", () => { |
| 160 | + // These are ported from here: |
| 161 | + // https://github.com/apple/foundationdb/blob/becc01923a30c1bc2ba158b293dbb38de7585c72/bindings/java/src/test/com/apple/foundationdb/test/TupleTest.java#L109-L122 |
| 162 | + assertEncodesAs(BigInt("0x7fffffffffffffff"), [ |
| 163 | + 0x1c, 0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, |
| 164 | + ]); |
| 165 | + assertEncodesAs(BigInt("0x8000000000000000"), [ |
| 166 | + 0x1c, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, |
| 167 | + ]); |
| 168 | + assertEncodesAs(BigInt("0xffffffffffffffff"), [ |
| 169 | + 0x1c, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, |
| 170 | + ]); |
| 171 | + assertEncodesAs(BigInt("0x10000000000000000"), [ |
| 172 | + 0x1d, 0x09, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, |
| 173 | + ]); |
| 174 | + assertEncodesAs(-0xffffffff, [0x10, 0x00, 0x00, 0x00, 0x00]); |
| 175 | + assertEncodesAs(-BigInt("0x7ffffffffffffffe"), [ |
| 176 | + 0x0c, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, |
| 177 | + ]); |
| 178 | + assertEncodesAs(-BigInt("0x7fffffffffffffff"), [ |
| 179 | + 0x0c, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, |
| 180 | + ]); |
| 181 | + assertEncodesAs(-BigInt("0x8000000000000000"), [ |
| 182 | + 0x0c, 0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, |
| 183 | + ]); |
| 184 | + assertEncodesAs(-BigInt("0x8000000000000001"), [ |
| 185 | + 0x0c, 0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff - 1, |
| 186 | + ]); |
| 187 | + assertEncodesAs(-BigInt("0xffffffffffffffff"), [ |
| 188 | + 0x0c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, |
| 189 | + ]); |
| 190 | + }); |
| 191 | + |
| 192 | + it("throws when asked to encode a bigint larger than 255 bytes", () => { |
| 193 | + tuple.pack(BigInt(256) ** BigInt(254)); // should be ok |
| 194 | + |
| 195 | + // What about 255? That currently throws and I'm not sure if that behaviour is correct or not. TODO. |
| 196 | + |
| 197 | + expect(() => { |
| 198 | + tuple.pack(BigInt(256) ** BigInt(256)); |
| 199 | + }).toThrow(); |
| 200 | + }); |
| 201 | + |
| 202 | + it("preserves encoding of values in strict mode", () => { |
| 203 | + // There's a few ways NaN is encoded. |
| 204 | + assertRoundTripBytes(hexToBytes("210007ffffffffffff"), true); // double |
| 205 | + assertRoundTripBytes(hexToBytes("21fff8000000000000"), true); |
| 206 | + assertRoundTripBytes(hexToBytes("20ffc00000"), true); // TODO: |
| 207 | + assertRoundTripBytes(hexToBytes("20003fffff"), true); |
| 208 | + // Do any other nan encodings exist? |
| 209 | + |
| 210 | + // Also any regular integers should be preserved. |
| 211 | + assertRoundTripBytes(hexToBytes("2080000000"), true); |
| 212 | + assertRoundTripBytes(hexToBytes("218000000000000000"), true); |
| 213 | + }); |
| 214 | + |
| 215 | + it("preserves encoding of exotic numbers", () => { |
| 216 | + // I'm sure there's lots more I'm missing here. |
| 217 | + assertRoundTripBytes(hexToBytes("217fffffffffffffff"), true); // This is -0. |
| 218 | + }); |
| 219 | + |
| 220 | + it("stalls on invalid input", () => { |
| 221 | + tuple.unpack( |
| 222 | + tuple.unpack( |
| 223 | + asciiToBytes( |
| 224 | + "\x01\x01tester_output\x00\xff\x01workspace\x01\x00" |
| 225 | + ) |
| 226 | + )[0] as Uint8Array |
| 227 | + ); |
| 228 | + }); |
| 229 | + |
| 230 | + describe("Conformance tests", () => { |
| 231 | + // These are from the examples here: |
| 232 | + // https://github.com/apple/foundationdb/blob/master/design/tuple.md |
| 233 | + |
| 234 | + const testConformance = ( |
| 235 | + name: string, |
| 236 | + value: TupleItem, |
| 237 | + bytes: Uint8Array | string |
| 238 | + ) => { |
| 239 | + it(name, () => assertEncodesAs(value, bytes)); |
| 240 | + }; |
| 241 | + |
| 242 | + testConformance("null", null, "\x00"); |
| 243 | + testConformance("false", false, "\x26"); |
| 244 | + testConformance("true", true, "\x27"); |
| 245 | + testConformance( |
| 246 | + "bytes", |
| 247 | + asciiToBytes("foo\x00bar"), |
| 248 | + "\x01foo\x00\xffbar\x00" |
| 249 | + ); |
| 250 | + testConformance("string", "F\u00d4O\u0000bar", "\x02F\xc3\x94O\x00\xffbar\x00"); |
| 251 | + // TODO: Nested tuple |
| 252 | + testConformance( |
| 253 | + "nested tuples", |
| 254 | + [asciiToBytes("foo\x00bar"), null, []], |
| 255 | + "\x05\x01foo\x00\xffbar\x00\x00\xff\x05\x00\x00" |
| 256 | + ); |
| 257 | + testConformance("zero", 0, "\x14"); // zero |
| 258 | + testConformance("integer", -5551212, "\x11\xabK\x93"); // integer |
| 259 | + // testConformance(-42. |
| 260 | + // testConformance('nan float', NaN, hexToBytes('0007ffffffffffff') |
| 261 | + testConformance("nan double", NaN, hexToBytes("21fff8000000000000")); |
| 262 | + testConformance( |
| 263 | + "bound version stamp", |
| 264 | + { type: "versionstamp", value: new Uint8Array(12).fill(0xe3) }, |
| 265 | + hexToBytes("33e3e3e3e3e3e3e3e3e3e3e3e3") |
| 266 | + ); |
| 267 | + // TODO: unbound versionstamps |
| 268 | + }); |
| 269 | +}); |
0 commit comments