diff --git a/tests/unit/test_serial.nim b/tests/unit/test_serial.nim index 62d518a..6061028 100644 --- a/tests/unit/test_serial.nim +++ b/tests/unit/test_serial.nim @@ -1,4 +1,4 @@ -import std/options +import std/[math, options] import unittest import results import ffi @@ -20,6 +20,22 @@ type Color = enum cGreen cBlue +type BytesHolder {.ffi.} = object + blob: seq[byte] + +type DeepInner {.ffi.} = object + tag: string + nested: Nested + +type ComplexContainer {.ffi.} = object + ids: seq[int] + names: seq[string] + points: seq[Point] + maybePoint: Option[Point] + maybeNames: Option[seq[string]] + flags: seq[Option[int]] + blob: seq[byte] + suite "CBOR primitives round-trip": test "bool true": let bytes = cborEncode(true) @@ -242,3 +258,322 @@ suite "cborEncodeShared": c_free(sd) check sl == 1 check sd[0] == 0x60'u8 + +suite "CBOR boundaries": + test "int8": + for v in [low(int8), int8(-1), int8(0), int8(1), high(int8)]: + let bytes = cborEncode(v) + check cborDecode(bytes, int8).value == v + + test "int16": + for v in [low(int16), int16(-1), int16(0), int16(1), high(int16)]: + let bytes = cborEncode(v) + check cborDecode(bytes, int16).value == v + + test "int32": + for v in [low(int32), int32(-1), int32(0), int32(1), high(int32)]: + let bytes = cborEncode(v) + check cborDecode(bytes, int32).value == v + + test "int64": + for v in [low(int64), int64(-1), int64(0), int64(1), high(int64)]: + let bytes = cborEncode(v) + check cborDecode(bytes, int64).value == v + + test "uint8": + for v in [uint8(0), uint8(1), high(uint8)]: + let bytes = cborEncode(v) + check cborDecode(bytes, uint8).value == v + + test "uint16": + for v in [uint16(0), uint16(1), high(uint16)]: + let bytes = cborEncode(v) + check cborDecode(bytes, uint16).value == v + + test "uint32": + for v in [uint32(0), uint32(1), high(uint32)]: + let bytes = cborEncode(v) + check cborDecode(bytes, uint32).value == v + + test "uint64 (UINT64_MAX)": + for v in [uint64(0), uint64(1), high(uint64)]: + let bytes = cborEncode(v) + check cborDecode(bytes, uint64).value == v + + test "float32 finite values incl. ±FLT_MAX": + for v in [float32(0.0), float32(-0.0), float32(1.5), float32(-1.5), + float32(3.4028235e38), float32(-3.4028235e38)]: + let bytes = cborEncode(v) + check cborDecode(bytes, float32).value == v + + test "float64 NaN": + let bytes = cborEncode(NaN) + let back = cborDecode(bytes, float64) + check back.isOk + check back.value.classify == fcNan + + test "float32 NaN": + let v: float32 = NaN + let bytes = cborEncode(v) + let back = cborDecode(bytes, float32) + check back.isOk + check back.value.classify == fcNan + + test "float64 +Inf and -Inf": + let posBytes = cborEncode(Inf) + check cborDecode(posBytes, float64).value == Inf + let negBytes = cborEncode(NegInf) + check cborDecode(negBytes, float64).value == NegInf + + test "float32 +Inf and -Inf": + let pos: float32 = Inf + let neg: float32 = NegInf + check cborDecode(cborEncode(pos), float32).value == pos + check cborDecode(cborEncode(neg), float32).value == neg + + test "float64 subnormal": + # 5e-324 is the smallest positive float64 subnormal (Double.MIN_VALUE). + let v = 5e-324 + let bytes = cborEncode(v) + let back = cborDecode(bytes, float64) + check back.isOk + check back.value == v + check back.value.classify == math.fcSubnormal + + test "float32 subnormal (compared by bit pattern)": + # The smallest positive float32 subnormal has bit pattern 0x00000001. + # `math.classify` widens to float64 and would re-classify this as normal, + # so we compare the raw 32-bit bit pattern instead. + let v = cast[float32](0x00000001'u32) + let bytes = cborEncode(v) + let back = cborDecode(bytes, float32) + check back.isOk + check cast[uint32](back.value) == 0x00000001'u32 + +suite "CBOR round-trips": + test "seq[byte]": + let bs: seq[byte] = @[0x00'u8, 0x01'u8, 0x7f'u8, 0x80'u8, 0xff'u8] + let bytes = cborEncode(bs) + check cborDecode(bytes, seq[byte]).value == bs + + test "empty seq[byte] encodes as CBOR bytes(0)": + let bs: seq[byte] = @[] + let bytes = cborEncode(bs) + # 0x40 = major type 2 (bytes), length 0. + check bytes.len == 1 + check bytes[0] == 0x40'u8 + check cborDecode(bytes, seq[byte]).value == bs + + test "seq[byte] with embedded NUL bytes": + let bs: seq[byte] = @[0x00'u8, 0xde'u8, 0x00'u8, 0xad'u8, 0x00'u8] + let bytes = cborEncode(bs) + let back = cborDecode(bytes, seq[byte]) + check back.isOk + check back.value == bs + check back.value.len == 5 + + test "string with embedded NUL keeps length, not C-string termination": + let s = "a\0b\0c" + check s.len == 5 + let bytes = cborEncode(s) + let back = cborDecode(bytes, string) + check back.isOk + check back.value.len == 5 + check back.value == s + + test "object with seq[byte] field": + let h = BytesHolder(blob: @[0x00'u8, 0xff'u8, 0x10'u8, 0x00'u8]) + let bytes = cborEncode(h) + let back = cborDecode(bytes, BytesHolder) + check back.isOk + check back.value.blob == h.blob + + test "seq[Option[int]] mixes some and none": + let s = @[some(1), none(int), some(-3), none(int), some(0)] + let bytes = cborEncode(s) + let back = cborDecode(bytes, seq[Option[int]]) + check back.isOk + check back.value == s + + test "Option[seq[int]] some": + let o = some(@[1, 2, 3]) + let bytes = cborEncode(o) + let back = cborDecode(bytes, Option[seq[int]]) + check back.isOk + check back.value == o + + test "Option[seq[int]] none": + let o = none(seq[int]) + let bytes = cborEncode(o) + let back = cborDecode(bytes, Option[seq[int]]) + check back.isOk + check back.value == o + + test "three-level struct nesting": + let d = DeepInner(tag: "outer", + nested: Nested(label: "mid", point: Point(x: 11, y: 22))) + let bytes = cborEncode(d) + let back = cborDecode(bytes, DeepInner) + check back.isOk + check back.value.tag == "outer" + check back.value.nested.label == "mid" + check back.value.nested.point.x == 11 + check back.value.nested.point.y == 22 + + test "seq[Nested] preserves element order and content": + let s = @[ + Nested(label: "a", point: Point(x: 1, y: 2)), + Nested(label: "b", point: Point(x: 3, y: 4)), + Nested(label: "c", point: Point(x: 5, y: 6)), + ] + let bytes = cborEncode(s) + let back = cborDecode(bytes, seq[Nested]) + check back.isOk + check back.value.len == 3 + check back.value[1].label == "b" + check back.value[2].point.y == 6 + + test "Option[Nested] some/none": + let some1 = some(Nested(label: "x", point: Point(x: 7, y: 8))) + let back1 = cborDecode(cborEncode(some1), Option[Nested]) + check back1.isOk + check back1.value.isSome + check back1.value.get.label == "x" + + let none1 = none(Nested) + let back2 = cborDecode(cborEncode(none1), Option[Nested]) + check back2.isOk + check back2.value.isNone + + test "ComplexContainer with mixed nested fields": + let c = ComplexContainer( + ids: @[1, 2, 3], + names: @["alpha", "beta"], + points: @[Point(x: 1, y: 2), Point(x: 3, y: 4)], + maybePoint: some(Point(x: 9, y: 9)), + maybeNames: some(@["a", "b"]), + flags: @[some(1), none(int), some(2)], + blob: @[0x00'u8, 0xff'u8, 0x42'u8], + ) + let bytes = cborEncode(c) + let back = cborDecode(bytes, ComplexContainer) + check back.isOk + check back.value.ids == c.ids + check back.value.names == c.names + check back.value.points.len == 2 + check back.value.points[1].y == 4 + check back.value.maybePoint.isSome + check back.value.maybePoint.get.x == 9 + check back.value.maybeNames == c.maybeNames + check back.value.flags == c.flags + check back.value.blob == c.blob + + test "ComplexContainer with all-empty / all-none fields": + let c = ComplexContainer( + ids: @[], + names: @[], + points: @[], + maybePoint: none(Point), + maybeNames: none(seq[string]), + flags: @[], + blob: @[], + ) + let bytes = cborEncode(c) + let back = cborDecode(bytes, ComplexContainer) + check back.isOk + check back.value.ids.len == 0 + check back.value.names.len == 0 + check back.value.points.len == 0 + check back.value.maybePoint.isNone + check back.value.maybeNames.isNone + check back.value.flags.len == 0 + check back.value.blob.len == 0 + + # Sizes chosen to cross the 24/256/65536-byte CBOR length-encoding + # boundaries and the encoder's internal buffer-grow thresholds. + + test "string >64 KiB": + const n = 70_000 + var big = newString(n) + for i in 0 ..< n: + big[i] = char(ord('a') + (i mod 26)) + let bytes = cborEncode(big) + let back = cborDecode(bytes, string) + check back.isOk + check back.value.len == n + check back.value == big + + test "seq[byte] >64 KiB with embedded NULs": + const n = 70_000 + var blob = newSeq[byte](n) + for i in 0 ..< n: + blob[i] = byte(i mod 256) + let bytes = cborEncode(blob) + let back = cborDecode(bytes, seq[byte]) + check back.isOk + check back.value.len == n + check back.value == blob + + test "string >1 MiB": + const n = 1_200_000 + var big = newString(n) + for i in 0 ..< n: + big[i] = char(ord('a') + (i mod 26)) + let bytes = cborEncode(big) + let back = cborDecode(bytes, string) + check back.isOk + check back.value.len == n + check back.value[0] == big[0] + check back.value[n - 1] == big[n - 1] + check back.value == big + + test "seq[byte] >1 MiB": + const n = 1_200_000 + var blob = newSeq[byte](n) + for i in 0 ..< n: + blob[i] = byte(i mod 256) + let bytes = cborEncode(blob) + let back = cborDecode(bytes, seq[byte]) + check back.isOk + check back.value.len == n + check back.value == blob + + test "seq[Point] with 100k elements": + const n = 100_000 + var pts = newSeq[Point](n) + for i in 0 ..< n: + pts[i] = Point(x: i, y: -i) + let bytes = cborEncode(pts) + let back = cborDecode(bytes, seq[Point]) + check back.isOk + check back.value.len == n + check back.value[0].x == 0 + check back.value[n - 1].x == n - 1 + check back.value[n - 1].y == -(n - 1) + +suite "CBOR void / null sentinel": + ## `CborNullByte` is the wire sentinel used by `ffi_thread_request` to + ## carry a "successful but no value" reply (an `.ffi.` proc whose handler + ## returns `Result[void, string]`). See `ffi/cbor_serial.nim` and + ## `ffi/ffi_thread_request.nim` for the producer side. + + test "CborNullByte equals CBOR null (0xf6)": + check CborNullByte == 0xf6'u8 + + test "top-level none(T) encodes as the single sentinel byte": + let bytes = cborEncode(none(int)) + check bytes.len == 1 + check bytes[0] == CborNullByte + + test "decoding the sentinel byte as Option[T] yields none": + let bytes = @[CborNullByte] + let back = cborDecode(bytes, Option[int]) + check back.isOk + check back.value.isNone + + test "decoding the sentinel as a required object type errors out": + # A consumer that expects a real payload but is handed the void sentinel + # must fail explicitly rather than silently materializing a zeroed value. + let bytes = @[CborNullByte] + let back = cborDecode(bytes, Point) + check back.isErr