Files
MapLibreParticleLab/test/particle/qbin_v3_decoder.test.cpp
2026-09-21 08:03:39 +09:00

619 lines
23 KiB
C++

#include <mbgl/particle/ingress/qbin_v3_decoder.hpp>
#include <gtest/gtest.h>
#include <algorithm>
#include <array>
#include <cmath>
#include <cstring>
#include <optional>
#include <vector>
using namespace mbgl;
namespace {
constexpr size_t Header = 112;
constexpr size_t Descriptor = 32;
void u16(std::vector<uint8_t>& b, size_t o, uint16_t v) {
b[o] = uint8_t(v);
b[o + 1] = uint8_t(v >> 8);
}
void u32(std::vector<uint8_t>& b, size_t o, uint32_t v) {
for (size_t i = 0; i < 4; ++i) b[o + i] = uint8_t(v >> (8 * i));
}
uint32_t r32(const std::vector<uint8_t>& b, size_t o) {
uint32_t v = 0;
for (size_t i = 0; i < 4; ++i) v |= uint32_t(b[o + i]) << (8 * i);
return v;
}
void u64(std::vector<uint8_t>& b, size_t o, uint64_t v) {
for (size_t i = 0; i < 8; ++i) b[o + i] = uint8_t(v >> (8 * i));
}
void i64(std::vector<uint8_t>& b, size_t o, int64_t v) {
u64(b, o, static_cast<uint64_t>(v));
}
void f32(std::vector<uint8_t>& b, size_t o, float v) {
uint32_t bits = 0;
std::memcpy(&bits, &v, sizeof(bits));
u32(b, o, bits);
}
void f64(std::vector<uint8_t>& b, size_t o, double v) {
uint64_t bits = 0;
std::memcpy(&bits, &v, sizeof(bits));
u64(b, o, bits);
}
size_t a16(size_t x) { return (x + 15u) & ~size_t(15u); }
uint32_t crc(const std::vector<uint8_t>& b) {
uint32_t c = ~0u;
for (size_t i = 0; i < b.size(); ++i) {
const uint8_t x = i >= 76 && i < 80 ? 0 : b[i];
c ^= x;
for (int j = 0; j < 8; ++j) c = (c >> 1) ^ ((c & 1u) ? 0xedb88320u : 0);
}
return ~c;
}
void recalc(std::vector<uint8_t>& b) { u32(b, 76, crc(b)); }
bool validAt(const std::vector<uint8_t>& v, uint64_t i) {
return ((v[i >> 3] >> (i & 7u)) & 1u) != 0;
}
int64_t normDir(int64_t x, uint32_t n) {
if (x < 0) x += n;
else if (x >= int64_t(n)) x -= n;
return x;
}
uint64_t zigzag(int64_t r) {
return r >= 0 ? uint64_t(2 * r) : uint64_t(-2 * r - 1);
}
struct Bits {
std::vector<uint8_t> bytes;
size_t pos = 0;
void bit(uint8_t value) {
if ((pos & 7u) == 0) bytes.push_back(0);
if (value) bytes.back() |= uint8_t(1u << (pos & 7u));
++pos;
}
void bits(uint32_t value, uint8_t count) {
for (uint8_t i = 0; i < count; ++i) bit((value >> i) & 1u);
}
};
struct PlaneSpec {
uint16_t semantic = 1;
uint8_t dtype = 1;
uint8_t flags = 1;
float scale = 1.0f;
float offset = 0.0f;
uint16_t unit = 0;
uint16_t group = 0;
uint8_t valueClass = 0;
uint8_t component = 0;
std::vector<int64_t> values;
};
int64_t predict(const PlaneSpec& p,
const std::vector<uint8_t>& validity,
uint32_t width,
uint32_t row,
uint32_t col,
uint32_t n) {
const uint64_t i = uint64_t(row) * width + col;
const bool left = col > 0 && validAt(validity, i - 1);
const bool up = row > 0 && validAt(validity, i - width);
const bool upperLeft = row > 0 && col > 0 && validAt(validity, i - width - 1);
int64_t predicted = 0;
if (left && up && upperLeft) {
predicted = p.values[i - 1] + p.values[i - width] - p.values[i - width - 1];
} else if (left) {
predicted = p.values[i - 1];
} else if (up) {
predicted = p.values[i - width];
}
return n ? normDir(predicted, n) : predicted;
}
std::vector<uint8_t> encodePlane(const PlaneSpec& p,
const std::vector<uint8_t>& validity,
uint32_t width,
uint32_t height,
std::optional<uint8_t> forcedK = std::nullopt) {
const uint32_t n = p.valueClass == 2
? uint32_t(std::floor(360.0 / double(p.scale) + 0.5))
: 0;
std::vector<uint8_t> out;
for (uint32_t rb = 0; rb < height; rb += 16) {
for (uint32_t cb = 0; cb < width; cb += 16) {
std::vector<uint64_t> symbols;
for (uint32_t row = rb; row < std::min(rb + 16u, height); ++row) {
for (uint32_t col = cb; col < std::min(cb + 16u, width); ++col) {
const uint64_t i = uint64_t(row) * width + col;
if (!validAt(validity, i)) continue;
const int64_t predicted = predict(p, validity, width, row, col, n);
int64_t residual = p.values[i] - predicted;
if (n) {
int64_t m = residual;
if (m < 0) m += n;
residual = 2 * m > int64_t(n) ? m - n
: 2 * m < int64_t(n) ? m
: -int64_t(n / 2);
}
symbols.push_back(zigzag(residual));
}
}
if (symbols.empty()) continue;
uint8_t bestK = forcedK.value_or(0);
if (!forcedK) {
uint64_t bestBits = ~uint64_t(0);
for (uint8_t k = 0; k <= 31; ++k) {
uint64_t bits = 0;
for (const auto z : symbols) bits += (z >> k) + 1u + k;
if (bits < bestBits) {
bestBits = bits;
bestK = k;
}
}
}
out.push_back(bestK);
Bits writer;
for (const auto z : symbols) {
const uint64_t q = z >> bestK;
for (uint64_t j = 0; j < q; ++j) writer.bit(0);
writer.bit(1);
if (bestK) writer.bits(uint32_t(z & ((uint64_t(1) << bestK) - 1u)), bestK);
}
out.insert(out.end(), writer.bytes.begin(), writer.bytes.end());
}
}
return out;
}
std::vector<uint8_t> rawBytes(const PlaneSpec& p, const std::vector<uint8_t>& validity) {
const size_t stride = p.dtype == 1 || p.dtype == 2 ? 2u : 1u;
std::vector<uint8_t> out(p.values.size() * stride, 0);
for (size_t i = 0; i < p.values.size(); ++i) {
if (!validAt(validity, i)) continue;
if (stride == 2) {
const uint16_t v = static_cast<uint16_t>(p.values[i]);
out[i * 2] = uint8_t(v);
out[i * 2 + 1] = uint8_t(v >> 8);
} else {
out[i] = static_cast<uint8_t>(p.values[i]);
}
}
return out;
}
std::vector<uint8_t> fixture(uint32_t width,
uint32_t height,
std::vector<uint8_t> validity,
std::vector<PlaneSpec> planes,
std::optional<uint8_t> forcedK = std::nullopt,
uint16_t fieldType = 999) {
const uint64_t cells = uint64_t(width) * height;
const uint16_t count = uint16_t(planes.size() + 1);
const size_t payloadOffset = Header + size_t(count) * Descriptor;
struct Encoded { PlaneSpec p; std::vector<uint8_t> bytes; };
std::vector<Encoded> encoded;
for (auto& p : planes) encoded.push_back({p, encodePlane(p, validity, width, height, forcedK)});
size_t payloadLength = 0;
for (const auto& e : encoded) payloadLength = a16(payloadLength + e.bytes.size());
payloadLength = a16(payloadLength + validity.size());
std::vector<uint8_t> b(payloadOffset + payloadLength, 0);
std::memcpy(b.data(), "QBV3", 4);
b[4] = 3;
b[5] = 1;
u16(b, 6, fieldType);
b[8] = b[9] = b[10] = 1;
u16(b, 12, count);
u32(b, 16, width);
u32(b, 20, height);
i64(b, 24, 100);
i64(b, 32, 90);
f64(b, 40, -10);
f64(b, 48, -10);
f64(b, 56, 10);
f64(b, 64, 10);
u32(b, 72, payloadLength);
u16(b, 80, 0xffff);
size_t off = 0;
for (size_t i = 0; i < encoded.size(); ++i) {
const auto& e = encoded[i];
const size_t d = Header + i * Descriptor;
u16(b, d, e.p.semantic);
b[d + 2] = e.p.dtype;
b[d + 3] = e.p.flags;
f32(b, d + 4, e.p.scale);
f32(b, d + 8, e.p.offset);
u32(b, d + 12, cells);
u32(b, d + 16, off);
u32(b, d + 20, e.bytes.size());
u16(b, d + 24, e.p.unit);
u16(b, d + 26, e.p.group);
b[d + 28] = e.p.valueClass;
b[d + 29] = e.p.component;
std::copy(e.bytes.begin(), e.bytes.end(), b.begin() + payloadOffset + off);
off = a16(off + e.bytes.size());
}
const size_t d = Header + encoded.size() * Descriptor;
u16(b, d, 3);
b[d + 2] = 6;
b[d + 3] = 1;
f32(b, d + 4, 1.0f);
f32(b, d + 8, 0.0f);
u32(b, d + 12, cells);
u32(b, d + 16, off);
u32(b, d + 20, validity.size());
b[d + 28] = 3;
std::copy(validity.begin(), validity.end(), b.begin() + payloadOffset + off);
recalc(b);
return b;
}
std::vector<uint8_t> fixtureV2(uint32_t width,
uint32_t height,
const std::vector<uint8_t>& validity,
const std::vector<PlaneSpec>& planes,
uint16_t fieldType = 999) {
const uint64_t cells = uint64_t(width) * height;
const uint16_t count = uint16_t(planes.size() + 1);
const size_t payloadOffset = Header + size_t(count) * Descriptor;
struct RawPlane {
PlaneSpec p;
std::vector<uint8_t> bytes;
};
std::vector<RawPlane> raws;
for (const auto& p : planes) raws.push_back({p, rawBytes(p, validity)});
size_t payloadLength = 0;
for (const auto& r : raws) payloadLength = a16(payloadLength + r.bytes.size());
payloadLength = a16(payloadLength + validity.size());
std::vector<uint8_t> b(payloadOffset + payloadLength, 0);
std::memcpy(b.data(), "QBV2", 4);
b[4] = 2;
b[5] = 1;
u16(b, 6, fieldType);
b[8] = b[9] = b[10] = 1;
u16(b, 12, count);
u32(b, 16, width);
u32(b, 20, height);
i64(b, 24, 100);
i64(b, 32, 90);
f64(b, 40, -10);
f64(b, 48, -10);
f64(b, 56, 10);
f64(b, 64, 10);
u32(b, 72, payloadLength);
u16(b, 80, 0xffff);
size_t off = 0;
for (size_t i = 0; i < raws.size(); ++i) {
const auto& r = raws[i];
const size_t d = Header + i * Descriptor;
u16(b, d, r.p.semantic);
b[d + 2] = r.p.dtype;
b[d + 3] = r.p.flags;
f32(b, d + 4, r.p.scale);
f32(b, d + 8, r.p.offset);
u32(b, d + 12, cells);
u32(b, d + 16, off);
u32(b, d + 20, r.bytes.size());
u16(b, d + 24, r.p.unit);
u16(b, d + 26, r.p.group);
b[d + 28] = r.p.valueClass;
b[d + 29] = r.p.component;
std::copy(r.bytes.begin(), r.bytes.end(), b.begin() + payloadOffset + off);
off = a16(off + r.bytes.size());
}
const size_t d = Header + raws.size() * Descriptor;
u16(b, d, 3);
b[d + 2] = 6;
b[d + 3] = 1;
f32(b, d + 4, 1.0f);
f32(b, d + 8, 0.0f);
u32(b, d + 12, cells);
u32(b, d + 16, off);
u32(b, d + 20, validity.size());
b[d + 28] = 3;
std::copy(validity.begin(), validity.end(), b.begin() + payloadOffset + off);
recalc(b);
return b;
}
std::vector<uint8_t> validityBits(uint32_t cells, bool allValid = true) {
std::vector<uint8_t> v((cells + 7u) / 8u, allValid ? 0xffu : 0u);
if (allValid && (cells & 7u)) v.back() = uint8_t((1u << (cells & 7u)) - 1u);
return v;
}
particle::ingress::EncodedFieldRuntimeMetadata meta() {
return {"qbv3-test", 1, 1};
}
using Code = particle::ingress::QbinDecodeErrorCode;
} // namespace
TEST(ParticleQbinV3Decoder, DecodesAcrossBlockBoundaryAndDirectionWrap) {
constexpr uint32_t width = 17, height = 17, cells = width * height;
auto validity = validityBits(cells);
validity[3] &= uint8_t(~(1u << 1)); // one invalid cell
std::vector<int64_t> scalar(cells), direction(cells);
for (uint32_t r = 0; r < height; ++r) for (uint32_t c = 0; c < width; ++c) {
const size_t i = r * width + c;
scalar[i] = int64_t(r) * 40 - int64_t(c) * 7;
direction[i] = (350 + int64_t(c) * 3 + int64_t(r) * 11) % 360;
}
PlaneSpec a{1, 1, 1, 1.0f, 0.0f, 77, 0, 0, 0, scalar};
PlaneSpec d{2, 2, 1, 1.0f, 0.0f, 4, 0, 2, 0, direction};
auto bytes = fixture(width, height, validity, {a, d});
auto result = particle::ingress::QbinV3Decoder::decode(meta(), bytes);
ASSERT_TRUE(result.accepted()) << result.detail;
ASSERT_EQ(result.frame->planes.size(), 2u);
EXPECT_EQ(*result.frame->planes[0].storage, rawBytes(a, validity));
EXPECT_EQ(*result.frame->planes[1].storage, rawBytes(d, validity));
EXPECT_EQ(*result.frame->validityBits, validity);
}
TEST(ParticleQbinV3Decoder, AcceptsAllInvalidWithSharedZeroLengthOffsets) {
constexpr uint32_t width = 2, height = 2, cells = 4;
auto validity = validityBits(cells, false);
PlaneSpec a{1, 1, 1, 1.0f, 0.0f, 77, 0, 0, 0, {1,2,3,4}};
PlaneSpec d{2, 2, 1, 1.0f, 0.0f, 4, 0, 2, 0, {0,90,180,270}};
auto bytes = fixture(width, height, validity, {a, d});
const size_t d0 = Header, d1 = Header + Descriptor, dv = Header + 2 * Descriptor;
EXPECT_EQ(r32(bytes, d0 + 16), 0u);
EXPECT_EQ(r32(bytes, d1 + 16), 0u);
EXPECT_EQ(r32(bytes, dv + 16), 0u);
auto result = particle::ingress::QbinV3Decoder::decode(meta(), bytes);
ASSERT_TRUE(result.accepted()) << result.detail;
EXPECT_EQ(*result.frame->planes[0].storage, std::vector<uint8_t>(cells * 2, 0));
EXPECT_EQ(*result.frame->planes[1].storage, std::vector<uint8_t>(cells * 2, 0));
}
TEST(ParticleQbinV3Decoder, AcceptsNonOptimalRiceK) {
auto validity = validityBits(4);
PlaneSpec p{1, 1, 1, 1.0f, 0.0f, 77, 0, 0, 0, {0,0,0,0}};
auto bytes = fixture(2, 2, validity, {p}, uint8_t(5));
auto result = particle::ingress::QbinV3Decoder::decode(meta(), bytes);
EXPECT_TRUE(result.accepted()) << result.detail;
}
TEST(ParticleQbinV3Decoder, RejectsCrcDescriptorAndValidityCorruption) {
auto validity = validityBits(4);
PlaneSpec p{1, 1, 1, 1.0f, 0.0f, 77, 0, 0, 0, {1,2,3,4}};
auto bytes = fixture(2, 2, validity, {p});
bytes[6] ^= 1;
EXPECT_EQ(particle::ingress::QbinV3Decoder::decode(meta(), bytes).code, Code::CrcMismatch);
bytes = fixture(2, 2, validity, {p});
bytes[Header + 2] = 5;
recalc(bytes);
EXPECT_EQ(particle::ingress::QbinV3Decoder::decode(meta(), bytes).code, Code::InvalidDescriptor);
bytes = fixture(2, 2, validity, {p});
const size_t validityDescriptor = Header + Descriptor;
bytes[validityDescriptor + 2] = 3;
recalc(bytes);
EXPECT_EQ(particle::ingress::QbinV3Decoder::decode(meta(), bytes).code, Code::InvalidValidity);
}
TEST(ParticleQbinV3Decoder, RejectsMalformedRiceAndNonzeroBlockPadding) {
auto validity = validityBits(4);
PlaneSpec p{1, 1, 1, 1.0f, 0.0f, 77, 0, 0, 0, {0,0,0,0}};
auto bytes = fixture(2, 2, validity, {p});
const size_t payload = Header + 2 * Descriptor;
bytes[payload] = 32;
recalc(bytes);
EXPECT_EQ(particle::ingress::QbinV3Decoder::decode(meta(), bytes).code, Code::MalformedRice);
auto oneValid = validityBits(4, false);
oneValid[0] = 0x01;
bytes = fixture(2, 2, oneValid, {p}, uint8_t(0));
bytes[payload + 1] |= 0x80;
recalc(bytes);
EXPECT_EQ(particle::ingress::QbinV3Decoder::decode(meta(), bytes).code, Code::MalformedRice);
}
TEST(ParticleQbinV3Decoder, RejectsResidualOutsideLogicalRange) {
auto validity = validityBits(4);
PlaneSpec p{1, 3, 1, 1.0f, 0.0f, 77, 0, 0, 0, {0,0,0,0}};
auto bytes = fixture(2, 2, validity, {p}, uint8_t(31));
const size_t payload = Header + 2 * Descriptor;
ASSERT_GT(bytes.size(), payload + 4);
bytes[payload + 4] |= 0x80;
recalc(bytes);
EXPECT_EQ(particle::ingress::QbinV3Decoder::decode(meta(), bytes).code, Code::ResidualOutOfRange);
}
TEST(ParticleQbinV3Decoder, PreservesNodeRegisteredGridGeometry) {
auto validity = validityBits(9);
PlaneSpec p{1, 1, 1, 1.0f, 0.0f, 77, 0, 0, 0, std::vector<int64_t>(9, 0)};
auto bytes = fixture(3, 3, validity, {p});
auto result = particle::ingress::QbinV3Decoder::decode(meta(), bytes);
ASSERT_TRUE(result.accepted()) << result.detail;
const auto& g = result.frame->grid;
EXPECT_EQ(g.width, 3u);
EXPECT_EQ(g.height, 3u);
EXPECT_DOUBLE_EQ(g.west, -10.0);
EXPECT_DOUBLE_EQ(g.south, -10.0);
EXPECT_DOUBLE_EQ(g.east, 10.0);
EXPECT_DOUBLE_EQ(g.north, 10.0);
EXPECT_DOUBLE_EQ(g.west + (g.east - g.west) * 1.0 / (g.width - 1), 0.0);
EXPECT_DOUBLE_EQ(g.south + (g.north - g.south) * 1.0 / (g.height - 1), 0.0);
}
TEST(ParticleEncodedFieldIngress, DispatchesQbinV3AndRejectsUnknownMagic) {
auto validity = validityBits(4);
PlaneSpec u{1, 1, 1, 1.0f, 0.0f, 1, 1, 1, 1, {1,2,3,4}};
PlaneSpec v{2, 1, 1, 1.0f, 0.0f, 1, 1, 1, 2, {5,6,7,8}};
auto bytes = fixture(2, 2, validity, {u, v});
particle::ResolvedFieldProfileContract contract;
contract.profileID = "qbv3.wind.test";
contract.profileVersion = 1;
contract.requiredPlanes = {
{1, particle::FieldValueClass::VelocityComponent, 1, 1, particle::FieldComponent::UEast, true},
{2, particle::FieldValueClass::VelocityComponent, 1, 1, particle::FieldComponent::VNorth, true},
{3, particle::FieldValueClass::Validity, 0, 0, particle::FieldComponent::None, true},
};
auto accepted = particle::ingress::EncodedFieldIngress::decodeAndValidate(meta(), &contract, bytes);
EXPECT_TRUE(accepted.accepted()) << accepted.detail;
bytes[0] = 'X';
recalc(bytes);
auto rejected = particle::ingress::EncodedFieldIngress::decodeAndValidate(meta(), &contract, bytes);
EXPECT_EQ(rejected.code, particle::ingress::EncodedFieldIngressCode::DecodeRejected);
EXPECT_EQ(rejected.decodeCode, Code::BadMagic);
}
TEST(ParticleQbinV3Decoder, CoversAllCompressedIntegerDtypes) {
auto validity = validityBits(4);
PlaneSpec u8{1, 3, 1, 0.5f, 1.0f, 77, 0, 0, 0, {0, 255, 128, 1}};
PlaneSpec i8{2, 4, 1, 2.0f, -5.0f, 78, 0, 0, 0, {-128, 127, -1, 0}};
auto bytes = fixture(2, 2, validity, {u8, i8});
auto result = particle::ingress::QbinV3Decoder::decode(meta(), bytes);
ASSERT_TRUE(result.accepted()) << result.detail;
ASSERT_EQ(result.frame->planes.size(), 2u);
EXPECT_EQ(*result.frame->planes[0].storage, rawBytes(u8, validity));
EXPECT_EQ(*result.frame->planes[1].storage, rawBytes(i8, validity));
}
TEST(ParticleQbinV3Decoder, AcceptsOpaqueFieldTypeZeroAndUnknown) {
auto validity = validityBits(4);
PlaneSpec p{1, 1, 1, 1.0f, 0.0f, 77, 0, 0, 0, {1, 2, 3, 4}};
EXPECT_TRUE(particle::ingress::QbinV3Decoder::decode(meta(), fixture(2, 2, validity, {p}, std::nullopt, 0)).accepted());
EXPECT_TRUE(particle::ingress::QbinV3Decoder::decode(meta(), fixture(2, 2, validity, {p}, std::nullopt, 65535)).accepted());
}
TEST(ParticleQbinV3Decoder, RejectsHeaderVersionEndianReservedBoundsAndNegativeZero) {
auto validity = validityBits(4);
PlaneSpec p{1, 1, 1, 1.0f, 0.0f, 77, 0, 0, 0, {1, 2, 3, 4}};
auto bytes = fixture(2, 2, validity, {p});
bytes[0] = 'X';
recalc(bytes);
EXPECT_EQ(particle::ingress::QbinV3Decoder::decode(meta(), bytes).code, Code::BadMagic);
bytes = fixture(2, 2, validity, {p});
bytes[4] = 2;
recalc(bytes);
EXPECT_EQ(particle::ingress::QbinV3Decoder::decode(meta(), bytes).code, Code::UnsupportedVersion);
bytes = fixture(2, 2, validity, {p});
bytes[11] = 1;
recalc(bytes);
EXPECT_EQ(particle::ingress::QbinV3Decoder::decode(meta(), bytes).code, Code::ReservedNonZero);
bytes = fixture(2, 2, validity, {p});
bytes[16] = 0;
bytes[17] = 0;
bytes[18] = 0;
bytes[19] = 2;
recalc(bytes);
EXPECT_EQ(particle::ingress::QbinV3Decoder::decode(meta(), bytes).code, Code::LimitExceeded);
bytes = fixture(2, 2, validity, {p});
f64(bytes, 40, 11.0);
recalc(bytes);
EXPECT_EQ(particle::ingress::QbinV3Decoder::decode(meta(), bytes).code, Code::InvalidBounds);
bytes = fixture(2, 2, validity, {p});
u64(bytes, 40, 0x8000000000000000ull);
recalc(bytes);
EXPECT_EQ(particle::ingress::QbinV3Decoder::decode(meta(), bytes).code, Code::InvalidBounds);
}
TEST(ParticleQbinV3Decoder, RejectsDescriptorOrderDuplicateAndPacking) {
auto validity = validityBits(4);
PlaneSpec u{1, 1, 1, 1.0f, 0.0f, 1, 1, 1, 1, {1, 2, 3, 4}};
PlaneSpec v{2, 1, 1, 1.0f, 0.0f, 1, 1, 1, 2, {5, 6, 7, 8}};
auto bytes = fixture(2, 2, validity, {u, v});
u16(bytes, Header, 2);
u16(bytes, Header + Descriptor, 1);
recalc(bytes);
EXPECT_EQ(particle::ingress::QbinV3Decoder::decode(meta(), bytes).code, Code::NonCanonicalDescriptorOrder);
bytes = fixture(2, 2, validity, {u, v});
u16(bytes, Header + Descriptor, 1);
recalc(bytes);
EXPECT_EQ(particle::ingress::QbinV3Decoder::decode(meta(), bytes).code, Code::DuplicatePlaneKey);
bytes = fixture(2, 2, validity, {u, v});
u32(bytes, Header + Descriptor + 16, 0);
recalc(bytes);
EXPECT_EQ(particle::ingress::QbinV3Decoder::decode(meta(), bytes).code, Code::NonCanonicalPacking);
}
TEST(ParticleQbinV3Decoder, RejectsUnterminatedUnaryExtraEncodedByteAndRawOverflow) {
auto validity = validityBits(4);
PlaneSpec zero{1, 1, 1, 1.0f, 0.0f, 77, 0, 0, 0, {0, 0, 0, 0}};
const size_t payload = Header + 2 * Descriptor;
auto bytes = fixture(2, 2, validity, {zero}, uint8_t(0));
bytes[payload + 1] = 0;
recalc(bytes);
EXPECT_EQ(particle::ingress::QbinV3Decoder::decode(meta(), bytes).code, Code::MalformedRice);
bytes = fixture(2, 2, validity, {zero}, uint8_t(0));
const uint32_t length = r32(bytes, Header + 20);
ASSERT_LT(payload + length, bytes.size());
u32(bytes, Header + 20, length + 1);
bytes[payload + length] = 0;
recalc(bytes);
EXPECT_EQ(particle::ingress::QbinV3Decoder::decode(meta(), bytes).code, Code::MalformedRice);
PlaneSpec overflow{1, 3, 1, 1.0f, 0.0f, 77, 0, 0, 0, {300, 0, 0, 0}};
bytes = fixture(2, 2, validity, {overflow});
EXPECT_EQ(particle::ingress::QbinV3Decoder::decode(meta(), bytes).code, Code::InvalidCellPayload);
}
TEST(ParticleQbinV3Decoder, EnforcesDirectionHalfCircleNegativeTie) {
auto validity = validityBits(4);
PlaneSpec direction{1, 2, 1, 90.0f, 0.0f, 4, 0, 2, 0, {0, 2, 0, 2}};
auto bytes = fixture(2, 2, validity, {direction}, uint8_t(0));
auto canonical = particle::ingress::QbinV3Decoder::decode(meta(), bytes);
ASSERT_TRUE(canonical.accepted()) << canonical.detail;
const size_t payload = Header + 2 * Descriptor;
ASSERT_EQ(r32(bytes, Header + 20), 2u);
// canonical bits are 0x71: 0, -2, 0, 0. Replace the -2 symbol
// with the forbidden +2 half-circle representation (z=4), yielding 0xe1.
bytes[payload + 1] = 0xe1;
recalc(bytes);
EXPECT_EQ(particle::ingress::QbinV3Decoder::decode(meta(), bytes).code, Code::ResidualOutOfRange);
}
TEST(ParticleQbinV3Decoder, ContentIdentityMatchesQbinV2CanonicalFrame) {
auto validity = validityBits(4);
PlaneSpec u{1, 1, 1, 0.25f, 0.0f, 1, 1, 1, 1, {1, -2, 3, -4}};
PlaneSpec v{2, 1, 1, 0.25f, 0.0f, 1, 1, 1, 2, {5, 6, -7, 8}};
auto v2 = particle::ingress::QbinV2Decoder::decode(meta(), fixtureV2(2, 2, validity, {u, v}));
auto v3 = particle::ingress::QbinV3Decoder::decode(meta(), fixture(2, 2, validity, {u, v}));
ASSERT_TRUE(v2.accepted()) << v2.detail;
ASSERT_TRUE(v3.accepted()) << v3.detail;
ASSERT_EQ(v2.frame->planes.size(), v3.frame->planes.size());
for (size_t i = 0; i < v2.frame->planes.size(); ++i) {
EXPECT_EQ(*v2.frame->planes[i].storage, *v3.frame->planes[i].storage);
EXPECT_EQ(v2.frame->planes[i].contentIdentity, v3.frame->planes[i].contentIdentity);
}
EXPECT_EQ(*v2.frame->validityBits, *v3.frame->validityBits);
EXPECT_EQ(v2.frame->validityContentIdentity, v3.frame->validityContentIdentity);
}