2026-07-28 15:01:49 -03:00
|
|
|
// The canonical LIDL <-> JSON codec (cpp/logos_codec.h).
|
|
|
|
|
//
|
|
|
|
|
// This is the single implementation of an encoding that previously existed six
|
|
|
|
|
// times with divergent semantics, so the tests pin the CONTRACT, not just the
|
|
|
|
|
// happy path: which shapes decode, which throw, and that bytes stay tagged at
|
|
|
|
|
// any nesting depth (the case that used to compile and then either throw at
|
|
|
|
|
// call time or emit untagged number arrays).
|
|
|
|
|
|
|
|
|
|
#include <gtest/gtest.h>
|
|
|
|
|
|
|
|
|
|
#include "logos_codec.h"
|
|
|
|
|
|
|
|
|
|
#include <cstdint>
|
|
|
|
|
#include <map>
|
2026-07-31 01:05:28 -03:00
|
|
|
#include <optional>
|
2026-07-28 15:01:49 -03:00
|
|
|
#include <string>
|
|
|
|
|
#include <unordered_map>
|
|
|
|
|
#include <vector>
|
|
|
|
|
|
|
|
|
|
using nlohmann::json;
|
|
|
|
|
|
|
|
|
|
namespace {
|
|
|
|
|
|
|
|
|
|
const std::vector<uint8_t> kSpan = {0x00, 0x7f, 0x80, 0xff}; // spans the UTF-8 boundary
|
|
|
|
|
const char* kSpanB64 = "AH-A_w";
|
|
|
|
|
|
2026-07-31 01:05:28 -03:00
|
|
|
// Exactly what a generated record decoder hands a Codec for field `name`: the
|
|
|
|
|
// value when the key is present, a null json when it is not — the emitter writes
|
|
|
|
|
// `j.contains(f) ? j.at(f) : nlohmann::json()`. Reproduced here so the optional
|
|
|
|
|
// tests exercise the ABSENT case as it actually arrives, instead of asserting
|
|
|
|
|
// against a null the test made up.
|
|
|
|
|
json field(const json& obj, const char* name)
|
|
|
|
|
{
|
|
|
|
|
return obj.contains(name) ? obj.at(name) : json();
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-28 15:01:49 -03:00
|
|
|
} // namespace
|
|
|
|
|
|
|
|
|
|
// ── leaves ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
TEST(Codec, ScalarsRoundTrip)
|
|
|
|
|
{
|
|
|
|
|
EXPECT_EQ(logos::fromJson<std::string>(logos::toJson(std::string("hi"))), "hi");
|
|
|
|
|
EXPECT_EQ(logos::fromJson<int64_t>(logos::toJson<int64_t>(-7)), -7);
|
|
|
|
|
EXPECT_EQ(logos::fromJson<uint64_t>(logos::toJson<uint64_t>(9u)), 9u);
|
|
|
|
|
EXPECT_DOUBLE_EQ(logos::fromJson<double>(logos::toJson(1.5)), 1.5);
|
|
|
|
|
EXPECT_TRUE(logos::fromJson<bool>(logos::toJson(true)));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The codec itself is width-agnostic — it is a library, usable from any C++ that
|
|
|
|
|
// has an integer. The 64-bit-only rule is a MODULE CONTRACT enforced by the
|
|
|
|
|
// cdylib gate (logos-cpp-sdk), which rejects a uint32_t parameter and tells the
|
|
|
|
|
// author to write uint64_t. Keeping those concerns apart means internal callers
|
|
|
|
|
// are not forced to widen, while a published module interface cannot disagree
|
|
|
|
|
// with its declared C++ type about range.
|
|
|
|
|
TEST(Codec, CodecItselfIsWidthAgnostic)
|
|
|
|
|
{
|
|
|
|
|
EXPECT_EQ(logos::fromJson<int>(logos::toJson(42)), 42);
|
|
|
|
|
EXPECT_EQ(logos::fromJson<uint32_t>(logos::toJson<uint32_t>(42u)), 42u);
|
|
|
|
|
EXPECT_EQ(logos::fromJson<short>(logos::toJson<short>(-3)), -3);
|
|
|
|
|
EXPECT_EQ(logos::fromJson<size_t>(logos::toJson<size_t>(7u)), 7u);
|
|
|
|
|
EXPECT_EQ(logos::fromJson<uint8_t>(logos::toJson<uint8_t>(255u)), 255u);
|
|
|
|
|
EXPECT_FLOAT_EQ(logos::fromJson<float>(logos::toJson(0.5f)), 0.5f);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// uint64 above 2^63 must survive: nlohmann keeps unsigned and signed apart, so
|
|
|
|
|
// the value round-trips rather than wrapping through int64.
|
|
|
|
|
TEST(Codec, LargeUnsignedSurvives)
|
|
|
|
|
{
|
|
|
|
|
const uint64_t big = 18446744073709551615ull;
|
|
|
|
|
EXPECT_EQ(logos::fromJson<uint64_t>(logos::toJson(big)), big);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// A whole-valued double may arrive as an integer (JSON has one number type);
|
|
|
|
|
// float64 accepts it rather than failing a strict is_number_float check.
|
|
|
|
|
TEST(Codec, IntegralJsonNumberDecodesAsFloat64)
|
|
|
|
|
{
|
|
|
|
|
EXPECT_DOUBLE_EQ(logos::fromJson<double>(json(2)), 2.0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── bytes ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
TEST(Codec, BytesUseTheTaggedForm)
|
|
|
|
|
{
|
|
|
|
|
const json j = logos::toJson(kSpan);
|
|
|
|
|
ASSERT_TRUE(logos::isTaggedBytes(j));
|
|
|
|
|
EXPECT_EQ(j["_bytes"], kSpanB64);
|
|
|
|
|
EXPECT_EQ(logos::fromJson<std::vector<uint8_t>>(j), kSpan);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
TEST(Codec, EmptyBytesRoundTrip)
|
|
|
|
|
{
|
|
|
|
|
const std::vector<uint8_t> empty;
|
|
|
|
|
const json j = logos::toJson(empty);
|
|
|
|
|
EXPECT_EQ(j["_bytes"], "");
|
|
|
|
|
EXPECT_TRUE(logos::fromJson<std::vector<uint8_t>>(j).empty());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The size()==1 check: a map that merely CONTAINS a "_bytes" entry is a map, not
|
|
|
|
|
// bytes. The lp helper omitted this check while the plain wire enforced it, so
|
|
|
|
|
// the same value decoded two ways depending on the layer.
|
|
|
|
|
TEST(Codec, MultiKeyObjectIsNotBytes)
|
|
|
|
|
{
|
|
|
|
|
const json j = json{{"_bytes", "AA"}, {"x", 1}};
|
|
|
|
|
EXPECT_FALSE(logos::isTaggedBytes(j));
|
|
|
|
|
EXPECT_THROW(logos::bytesFromJson(j), logos::CodecError);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Padding tolerance: a padded encoder on the other side used to yield correct
|
|
|
|
|
// bytes in one copy, empty in another and None in Rust.
|
|
|
|
|
TEST(Codec, PaddedBase64Decodes)
|
|
|
|
|
{
|
|
|
|
|
EXPECT_EQ(logos::bytesFromJson(json{{"_bytes", "AH-A_w=="}}), kSpan);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The documented lenient set, for provider-side argument decoding only.
|
|
|
|
|
TEST(Codec, LenientBytesAcceptsWhatOtherLayersProduce)
|
|
|
|
|
{
|
|
|
|
|
EXPECT_EQ(logos::bytesFromJsonLenient(json("ab")), (std::vector<uint8_t>{'a', 'b'}));
|
|
|
|
|
EXPECT_EQ(logos::bytesFromJsonLenient(json(12)), (std::vector<uint8_t>{'1', '2'}));
|
|
|
|
|
EXPECT_EQ(logos::bytesFromJsonLenient(json::array({0, 255})),
|
|
|
|
|
(std::vector<uint8_t>{0x00, 0xff}));
|
|
|
|
|
// ...and the canonical form still wins over the array reading.
|
|
|
|
|
EXPECT_EQ(logos::bytesFromJsonLenient(logos::toJson(kSpan)), kSpan);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── composition ───────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
TEST(Codec, TypedScalarArraysRoundTripIncludingEmpty)
|
|
|
|
|
{
|
|
|
|
|
const std::vector<int64_t> ints = {1, -2, 3};
|
|
|
|
|
EXPECT_EQ(logos::fromJson<std::vector<int64_t>>(logos::toJson(ints)), ints);
|
|
|
|
|
|
|
|
|
|
const std::vector<std::string> strs;
|
|
|
|
|
EXPECT_TRUE(logos::fromJson<std::vector<std::string>>(logos::toJson(strs)).empty());
|
|
|
|
|
EXPECT_TRUE(logos::toJson(strs).is_array());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
TEST(Codec, ListOfBytesTagsEachElement)
|
|
|
|
|
{
|
|
|
|
|
const std::vector<std::vector<uint8_t>> list = {kSpan, {}, {0xde, 0xad}};
|
|
|
|
|
const json j = logos::toJson(list);
|
|
|
|
|
ASSERT_TRUE(j.is_array());
|
|
|
|
|
ASSERT_EQ(j.size(), 3u);
|
|
|
|
|
EXPECT_TRUE(logos::isTaggedBytes(j[0]));
|
|
|
|
|
EXPECT_TRUE(logos::isTaggedBytes(j[1])); // the empty element stays an element
|
|
|
|
|
EXPECT_EQ(logos::fromJson<std::vector<std::vector<uint8_t>>>(j), list);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The case that used to compile and then emit untagged nested number arrays.
|
|
|
|
|
TEST(Codec, ListOfListOfBytesTagsAtDepth)
|
|
|
|
|
{
|
|
|
|
|
const std::vector<std::vector<std::vector<uint8_t>>> nested = {{kSpan}, {}, {{}, {0x01}}};
|
|
|
|
|
const json j = logos::toJson(nested);
|
|
|
|
|
ASSERT_TRUE(j.is_array());
|
|
|
|
|
ASSERT_TRUE(j[0].is_array());
|
|
|
|
|
EXPECT_TRUE(logos::isTaggedBytes(j[0][0]));
|
|
|
|
|
EXPECT_EQ(logos::fromJson<std::vector<std::vector<std::vector<uint8_t>>>>(j), nested);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
TEST(Codec, MapOfBytesAndMapOfListsCompose)
|
|
|
|
|
{
|
|
|
|
|
const std::map<std::string, std::vector<uint8_t>> m = {{"a", kSpan}, {"b", {}}};
|
|
|
|
|
const json j = logos::toJson(m);
|
|
|
|
|
ASSERT_TRUE(j.is_object());
|
|
|
|
|
EXPECT_TRUE(logos::isTaggedBytes(j["a"]));
|
|
|
|
|
EXPECT_EQ((logos::fromJson<std::map<std::string, std::vector<uint8_t>>>(j)), m);
|
|
|
|
|
|
|
|
|
|
const std::map<std::string, std::vector<std::vector<uint8_t>>> deep = {{"k", {kSpan, {}}}};
|
|
|
|
|
EXPECT_EQ((logos::fromJson<std::map<std::string, std::vector<std::vector<uint8_t>>>>(
|
|
|
|
|
logos::toJson(deep))),
|
|
|
|
|
deep);
|
|
|
|
|
|
|
|
|
|
const std::unordered_map<std::string, int64_t> um = {{"n", 5}};
|
|
|
|
|
EXPECT_EQ((logos::fromJson<std::unordered_map<std::string, int64_t>>(logos::toJson(um))), um);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// `any` stops the recursion — the value passes through byte-identically, so a
|
|
|
|
|
// LogosMap keeps whatever the peer sent (tagged bytes included).
|
|
|
|
|
TEST(Codec, AnyPassesThroughVerbatim)
|
|
|
|
|
{
|
|
|
|
|
const json payload = json{{"nested", json{{"_bytes", kSpanB64}}}, {"n", 1}};
|
|
|
|
|
EXPECT_EQ(logos::fromJson<json>(logos::toJson(payload)), payload);
|
|
|
|
|
|
|
|
|
|
const std::vector<json> anyList = {json(1), json("s"), payload};
|
|
|
|
|
EXPECT_EQ(logos::fromJson<std::vector<json>>(logos::toJson(anyList)), anyList);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-31 01:05:28 -03:00
|
|
|
// ── optionals (?T) ────────────────────────────────────────────────────────
|
|
|
|
|
//
|
|
|
|
|
// `?T` is TWO-state: a value of T, or empty. The wire rule is asymmetric on
|
|
|
|
|
// purpose — absent and explicit null are the SAME state on the way in, and empty
|
|
|
|
|
// has exactly ONE spelling on the way out — so these tests pin both halves plus
|
|
|
|
|
// the fact that a round trip CANONICALISES rather than preserving the input.
|
|
|
|
|
|
|
|
|
|
// The liberal half. A record field that was never sent reaches the codec as a
|
|
|
|
|
// null json (see `field` above), so absent and null cannot be told apart here
|
|
|
|
|
// even in principle — and must not be.
|
|
|
|
|
TEST(CodecOptional, AbsentAndExplicitNullBothDecodeToEmpty)
|
|
|
|
|
{
|
|
|
|
|
const json sent = json{{"present", 1}, {"explicitNull", nullptr}};
|
|
|
|
|
|
|
|
|
|
EXPECT_EQ(logos::fromJson<std::optional<int64_t>>(field(sent, "missing")), std::nullopt);
|
|
|
|
|
EXPECT_EQ(logos::fromJson<std::optional<int64_t>>(field(sent, "explicitNull")), std::nullopt);
|
|
|
|
|
EXPECT_EQ(logos::fromJson<std::optional<int64_t>>(field(sent, "present")),
|
|
|
|
|
std::optional<int64_t>(1));
|
|
|
|
|
|
|
|
|
|
// A bare null, whatever produced it, is the same answer.
|
|
|
|
|
EXPECT_EQ(logos::fromJson<std::optional<std::string>>(json()), std::nullopt);
|
|
|
|
|
EXPECT_EQ(logos::fromJson<std::optional<std::string>>(json(nullptr)), std::nullopt);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The canonical half, and the layering boundary. This codec emits the POSITIONAL
|
|
|
|
|
// spelling (null) because it is handed a value, never a slot; omitting the key
|
|
|
|
|
// for a nullopt record field is the record emitter's job in logos-cpp-sdk. If
|
|
|
|
|
// this test ever starts asserting "nothing", key-omission has leaked one layer
|
|
|
|
|
// too deep — and a `to()` that can return nothing cannot fill an array position.
|
|
|
|
|
TEST(CodecOptional, EmptyEncodesAsNullAndRoundTripIsCanonicalising)
|
|
|
|
|
{
|
|
|
|
|
EXPECT_TRUE(logos::toJson(std::optional<int64_t>{}).is_null());
|
|
|
|
|
EXPECT_TRUE(logos::toJson(std::optional<std::string>{}).is_null());
|
|
|
|
|
|
|
|
|
|
EXPECT_EQ(logos::fromJson<std::optional<int64_t>>(logos::toJson(std::optional<int64_t>{})),
|
|
|
|
|
std::nullopt);
|
|
|
|
|
|
|
|
|
|
// Absent in, null out: the two input spellings converge on the one output
|
|
|
|
|
// spelling, which is what "canonicalising, not identity" means.
|
|
|
|
|
const json absent = field(json::object(), "x");
|
|
|
|
|
EXPECT_TRUE(logos::toJson(logos::fromJson<std::optional<int64_t>>(absent)).is_null());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
TEST(CodecOptional, PresentValueRoundTripsUnchanged)
|
|
|
|
|
{
|
|
|
|
|
const auto n = std::optional<int64_t>(-7);
|
|
|
|
|
EXPECT_EQ(logos::toJson(n), json(-7));
|
|
|
|
|
EXPECT_EQ(logos::fromJson<std::optional<int64_t>>(logos::toJson(n)), n);
|
|
|
|
|
|
|
|
|
|
const auto s = std::optional<std::string>("hi");
|
|
|
|
|
EXPECT_EQ(logos::toJson(s), json("hi"));
|
|
|
|
|
EXPECT_EQ(logos::fromJson<std::optional<std::string>>(logos::toJson(s)), s);
|
|
|
|
|
|
|
|
|
|
const auto b = std::optional<bool>(false);
|
|
|
|
|
EXPECT_EQ(logos::toJson(b), json(false)); // false is a VALUE, not empty
|
|
|
|
|
EXPECT_EQ(logos::fromJson<std::optional<bool>>(logos::toJson(b)), b);
|
|
|
|
|
|
|
|
|
|
const auto d = std::optional<double>(1.5);
|
|
|
|
|
EXPECT_EQ(logos::fromJson<std::optional<double>>(logos::toJson(d)), d);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Optional widens the domain by exactly ONE inhabitant. It is not a licence to
|
|
|
|
|
// accept anything: a present-but-wrong-typed value throws exactly as it would in
|
|
|
|
|
// a required slot, with the same path. Silently mapping a bad value to empty
|
|
|
|
|
// would be the "silent default" this codec exists to refuse, made invisible.
|
|
|
|
|
TEST(CodecOptional, PresentButWrongTypedStillThrows)
|
|
|
|
|
{
|
|
|
|
|
EXPECT_THROW(logos::fromJson<std::optional<int64_t>>(json("nope")), logos::CodecError);
|
|
|
|
|
EXPECT_THROW(logos::fromJson<std::optional<std::string>>(json(5)), logos::CodecError);
|
|
|
|
|
EXPECT_THROW(logos::fromJson<std::optional<bool>>(json(1)), logos::CodecError);
|
|
|
|
|
EXPECT_THROW(logos::fromJson<std::optional<std::vector<int64_t>>>(json("nope")),
|
|
|
|
|
logos::CodecError);
|
|
|
|
|
// Range checking survives the wrapper too.
|
|
|
|
|
EXPECT_THROW(logos::fromJson<std::optional<int64_t>>(json(1.5)), logos::CodecError);
|
|
|
|
|
|
|
|
|
|
// ...and the path still names the offending element, not the optional.
|
|
|
|
|
try {
|
|
|
|
|
logos::fromJson<std::vector<std::optional<int64_t>>>(
|
|
|
|
|
json::array({1, nullptr, "x"}));
|
|
|
|
|
FAIL() << "expected CodecError";
|
|
|
|
|
} catch (const logos::CodecError& e) {
|
|
|
|
|
EXPECT_NE(std::string(e.what()).find("[2]"), std::string::npos) << e.what();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// A required slot is untouched by any of this: null there still means "wrong
|
|
|
|
|
// type", which is the only reason absent-means-empty is safe to allow in an
|
|
|
|
|
// optional one.
|
|
|
|
|
TEST(CodecOptional, RequiredSlotStillRejectsNull)
|
|
|
|
|
{
|
|
|
|
|
EXPECT_THROW(logos::fromJson<int64_t>(json()), logos::CodecError);
|
|
|
|
|
EXPECT_THROW(logos::fromJson<std::string>(json(nullptr)), logos::CodecError);
|
|
|
|
|
EXPECT_THROW(logos::fromJson<std::vector<uint8_t>>(json()), logos::CodecError);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ?bstr — bytes keep their tagged form inside an optional, and an EMPTY byte
|
|
|
|
|
// string is a present value, not empty-the-state. Conflating the two is the
|
|
|
|
|
// obvious way to lose a deliberately-empty payload.
|
|
|
|
|
TEST(CodecOptional, OptionalOfBytes)
|
|
|
|
|
{
|
|
|
|
|
const auto some = std::optional<std::vector<uint8_t>>(kSpan);
|
|
|
|
|
const json j = logos::toJson(some);
|
|
|
|
|
ASSERT_TRUE(logos::isTaggedBytes(j));
|
|
|
|
|
EXPECT_EQ(j["_bytes"], kSpanB64);
|
|
|
|
|
EXPECT_EQ(logos::fromJson<std::optional<std::vector<uint8_t>>>(j), some);
|
|
|
|
|
|
|
|
|
|
EXPECT_TRUE(logos::toJson(std::optional<std::vector<uint8_t>>{}).is_null());
|
|
|
|
|
EXPECT_EQ(logos::fromJson<std::optional<std::vector<uint8_t>>>(json()), std::nullopt);
|
|
|
|
|
|
|
|
|
|
// Present-but-empty stays present.
|
|
|
|
|
const auto emptyBytes = std::optional<std::vector<uint8_t>>(std::vector<uint8_t>{});
|
|
|
|
|
const json ej = logos::toJson(emptyBytes);
|
|
|
|
|
EXPECT_FALSE(ej.is_null());
|
|
|
|
|
EXPECT_EQ(ej["_bytes"], "");
|
|
|
|
|
const auto back = logos::fromJson<std::optional<std::vector<uint8_t>>>(ej);
|
|
|
|
|
ASSERT_TRUE(back.has_value());
|
|
|
|
|
EXPECT_TRUE(back->empty());
|
|
|
|
|
|
|
|
|
|
// bstr's documented leniency does not become "accept anything": an object
|
|
|
|
|
// that is not the tagged form is still an error inside an optional.
|
|
|
|
|
EXPECT_THROW(logos::fromJson<std::optional<std::vector<uint8_t>>>(json{{"a", 1}}),
|
|
|
|
|
logos::CodecError);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ?[T] and ?{tstr: T}: an empty container is a present value. `[]` and null are
|
|
|
|
|
// different states, and only null is empty.
|
|
|
|
|
TEST(CodecOptional, OptionalOfContainerSeparatesEmptyFromMissing)
|
|
|
|
|
{
|
|
|
|
|
using OptList = std::optional<std::vector<int64_t>>;
|
|
|
|
|
const OptList present(std::vector<int64_t>{});
|
|
|
|
|
EXPECT_TRUE(logos::toJson(present).is_array());
|
|
|
|
|
EXPECT_TRUE(logos::fromJson<OptList>(json::array()).has_value());
|
|
|
|
|
EXPECT_EQ(logos::fromJson<OptList>(json()), std::nullopt);
|
|
|
|
|
|
|
|
|
|
const OptList vals(std::vector<int64_t>{1, -2});
|
|
|
|
|
EXPECT_EQ(logos::fromJson<OptList>(logos::toJson(vals)), vals);
|
|
|
|
|
|
|
|
|
|
using OptMap = std::optional<std::map<std::string, std::vector<uint8_t>>>;
|
|
|
|
|
const OptMap m(std::map<std::string, std::vector<uint8_t>>{{"a", kSpan}});
|
|
|
|
|
const json mj = logos::toJson(m);
|
|
|
|
|
ASSERT_TRUE(mj.is_object());
|
|
|
|
|
EXPECT_TRUE(logos::isTaggedBytes(mj["a"]));
|
|
|
|
|
EXPECT_EQ(logos::fromJson<OptMap>(mj), m);
|
|
|
|
|
EXPECT_EQ(logos::fromJson<OptMap>(json()), std::nullopt);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// [?T] and {tstr: ?T}: an empty element still occupies its position, and an
|
|
|
|
|
// empty map value still occupies its key. This is the concrete reason `to()`
|
|
|
|
|
// spells empty as null instead of omitting anything — dropping an array element
|
|
|
|
|
// changes arity, and dropping a map entry changes the key set, which is data.
|
|
|
|
|
TEST(CodecOptional, OptionalInsideContainersKeepsPositionAndKey)
|
|
|
|
|
{
|
|
|
|
|
const std::vector<std::optional<int64_t>> list = {1, std::nullopt, 3};
|
|
|
|
|
const json j = logos::toJson(list);
|
|
|
|
|
ASSERT_TRUE(j.is_array());
|
|
|
|
|
ASSERT_EQ(j.size(), 3u);
|
|
|
|
|
EXPECT_TRUE(j[1].is_null());
|
|
|
|
|
EXPECT_EQ(logos::fromJson<std::vector<std::optional<int64_t>>>(j), list);
|
|
|
|
|
|
|
|
|
|
const std::map<std::string, std::optional<std::string>> m = {{"a", "x"}, {"b", std::nullopt}};
|
|
|
|
|
const json mj = logos::toJson(m);
|
|
|
|
|
ASSERT_TRUE(mj.is_object());
|
|
|
|
|
EXPECT_EQ(mj.size(), 2u);
|
|
|
|
|
EXPECT_TRUE(mj["b"].is_null());
|
|
|
|
|
EXPECT_EQ((logos::fromJson<std::map<std::string, std::optional<std::string>>>(mj)), m);
|
|
|
|
|
|
|
|
|
|
// ?[?T] — the wrapper and the element are independent states.
|
|
|
|
|
using OptListOfOpt = std::optional<std::vector<std::optional<int64_t>>>;
|
|
|
|
|
const OptListOfOpt nested(std::vector<std::optional<int64_t>>{std::nullopt, 2});
|
|
|
|
|
EXPECT_EQ(logos::fromJson<OptListOfOpt>(logos::toJson(nested)), nested);
|
|
|
|
|
EXPECT_EQ(logos::fromJson<OptListOfOpt>(json()), std::nullopt);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ??T collapses, and that is the two-state rule holding at depth rather than an
|
|
|
|
|
// implementation accident: there is one empty spelling, so "present but inner
|
|
|
|
|
// empty" has nowhere to be written and comes back as outer-empty.
|
|
|
|
|
TEST(CodecOptional, NestedOptionalCollapses)
|
|
|
|
|
{
|
|
|
|
|
using OptOpt = std::optional<std::optional<int64_t>>;
|
|
|
|
|
|
|
|
|
|
const OptOpt outerEmpty;
|
|
|
|
|
const OptOpt innerEmpty(std::optional<int64_t>{});
|
|
|
|
|
EXPECT_TRUE(logos::toJson(outerEmpty).is_null());
|
|
|
|
|
EXPECT_TRUE(logos::toJson(innerEmpty).is_null()); // same byte, by design
|
|
|
|
|
|
|
|
|
|
EXPECT_EQ(logos::fromJson<OptOpt>(json()), outerEmpty);
|
|
|
|
|
EXPECT_NE(logos::fromJson<OptOpt>(json()), innerEmpty); // collapsed, not three-state
|
|
|
|
|
|
|
|
|
|
const OptOpt value(std::optional<int64_t>(4));
|
|
|
|
|
EXPECT_EQ(logos::fromJson<OptOpt>(logos::toJson(value)), value);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// PINS A TRAP, not a feature. JsonArg — the proxy that lets generated dispatch
|
|
|
|
|
// decode into the callee's exact parameter type without naming it — CANNOT
|
|
|
|
|
// deliver an optional, and the failure is silent-looking: for a
|
|
|
|
|
// std::optional<X> target, std::optional's own converting constructor
|
|
|
|
|
// optional(U&&) binds an rvalue reference to the proxy and out-ranks the
|
|
|
|
|
// proxy's const-qualified conversion function, so what gets decoded is X, and a
|
|
|
|
|
// null (an EMPTY optional) is rejected as a wrong-typed X.
|
|
|
|
|
//
|
|
|
|
|
// Verified against the alternatives before writing this down: making the
|
|
|
|
|
// conversion operator rvalue-qualified, or adding a conversion operator
|
|
|
|
|
// specifically for std::optional, either ties with the constructor (ambiguity
|
|
|
|
|
// error) or loses to it exactly as before. There is no signature that wins.
|
|
|
|
|
//
|
|
|
|
|
// So an optional parameter must be decoded by NAMING the type —
|
|
|
|
|
// fromJson<std::optional<X>>(j, path), which is what the cdylib backend already
|
|
|
|
|
// emits for every parameter. This test exists so that wiring JsonArg into
|
|
|
|
|
// optional dispatch fails here first, with the reason attached.
|
|
|
|
|
TEST(CodecOptional, JsonArgCannotDeliverOptionalsNameTheTypeInstead)
|
|
|
|
|
{
|
|
|
|
|
const json nullValue;
|
|
|
|
|
// Decoded as int64_t, not as std::optional<int64_t>: empty becomes an error.
|
|
|
|
|
EXPECT_THROW(([&] {
|
|
|
|
|
const std::optional<int64_t> x = logos::JsonArg(nullValue, "arg0");
|
|
|
|
|
(void)x;
|
|
|
|
|
}()),
|
|
|
|
|
logos::CodecError);
|
|
|
|
|
|
|
|
|
|
// Naming the type is the supported spelling, and it does the right thing.
|
|
|
|
|
EXPECT_EQ(logos::fromJson<std::optional<int64_t>>(nullValue, "arg0"), std::nullopt);
|
|
|
|
|
|
|
|
|
|
// A PRESENT value happens to survive the proxy (it decodes as X and is then
|
|
|
|
|
// wrapped by that same constructor) — which is exactly why the empty case
|
|
|
|
|
// has to be pinned: the trap only shows itself on the state that matters.
|
|
|
|
|
const json intValue(42);
|
|
|
|
|
const std::optional<int64_t> some = logos::JsonArg(intValue, "arg0");
|
|
|
|
|
EXPECT_EQ(some, std::optional<int64_t>(42));
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-28 15:01:49 -03:00
|
|
|
// ── failure modes ─────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
// A shape mismatch throws with the path, rather than silently substituting a
|
|
|
|
|
// default. Callers turn this into a structured error; the old behaviour differed
|
|
|
|
|
// per layer (throw in C++, silently-empty in Rust).
|
|
|
|
|
TEST(Codec, MismatchThrowsWithPath)
|
|
|
|
|
{
|
|
|
|
|
EXPECT_THROW(logos::fromJson<int64_t>(json("nope")), logos::CodecError);
|
|
|
|
|
EXPECT_THROW(logos::fromJson<std::vector<int64_t>>(json("nope")), logos::CodecError);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
logos::fromJson<std::vector<std::vector<int64_t>>>(json::array({json::array({1, "x"})}));
|
|
|
|
|
FAIL() << "expected CodecError";
|
|
|
|
|
} catch (const logos::CodecError& e) {
|
|
|
|
|
const std::string what = e.what();
|
|
|
|
|
EXPECT_NE(what.find("[0][1]"), std::string::npos) << what;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
logos::fromJson<std::map<std::string, int64_t>>(json{{"k", "x"}});
|
|
|
|
|
FAIL() << "expected CodecError";
|
|
|
|
|
} catch (const logos::CodecError& e) {
|
|
|
|
|
EXPECT_NE(std::string(e.what()).find(".k"), std::string::npos) << e.what();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The plain wire validates frames with the strict decode: a corrupt base64 body
|
|
|
|
|
// must be rejected, not silently decoded to fewer bytes. Consumer-facing decodes
|
|
|
|
|
// stay tolerant (PaddedBase64Decodes above), so both behaviours come from one
|
|
|
|
|
// implementation instead of four disagreeing copies.
|
|
|
|
|
TEST(Codec, CheckedDecodeRejectsCorruptInput)
|
|
|
|
|
{
|
|
|
|
|
std::vector<uint8_t> out;
|
|
|
|
|
EXPECT_TRUE(logos::b64UrlDecodeChecked("AH-A_w", out));
|
|
|
|
|
EXPECT_EQ(out, kSpan);
|
|
|
|
|
|
|
|
|
|
EXPECT_TRUE(logos::b64UrlDecodeChecked("AH-A_w==", out)); // padding tolerated
|
|
|
|
|
EXPECT_EQ(out, kSpan);
|
|
|
|
|
|
|
|
|
|
EXPECT_FALSE(logos::b64UrlDecodeChecked("AH-A_w!!", out)); // stray character
|
|
|
|
|
EXPECT_TRUE(out.empty());
|
|
|
|
|
|
|
|
|
|
EXPECT_FALSE(logos::b64UrlDecodeChecked("AH-A_wQQQ??", out));
|
|
|
|
|
EXPECT_FALSE(logos::b64UrlDecodeChecked("A", out)); // impossible length
|
|
|
|
|
}
|
2026-07-29 16:16:05 -03:00
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Adopted from logos-cpp-sdk tests/sdk/test_logos_json_bytes.cpp, which tested
|
|
|
|
|
// b64UrlEncode / bytesToJson back when logos_json.h carried its own copies of
|
|
|
|
|
// them. Those copies are gone (logos-cpp-sdk#117), so the coverage belongs with
|
|
|
|
|
// the canonical definitions rather than in a repo that would have to reach
|
|
|
|
|
// across for them.
|
|
|
|
|
//
|
|
|
|
|
// What these pin is not academic: a wrong alphabet, a stray '=', or a botched
|
|
|
|
|
// tail group silently corrupts every binary payload in the system, and the
|
|
|
|
|
// code-generation tests assert on generated source TEXT and cannot see it.
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
TEST(CodecBytes, RoundTripsEveryByteValue)
|
|
|
|
|
{
|
|
|
|
|
std::vector<uint8_t> all(256);
|
|
|
|
|
for (int i = 0; i < 256; ++i) all[i] = static_cast<uint8_t>(i);
|
|
|
|
|
|
|
|
|
|
EXPECT_EQ(logos::b64UrlDecode(logos::b64UrlEncode(all)), all);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
TEST(CodecBytes, UsesTheUrlSafeAlphabetAndOmitsPadding)
|
|
|
|
|
{
|
|
|
|
|
// 0xFB 0xFF encodes to the two characters that differ between the standard
|
|
|
|
|
// and URL-safe alphabets ('+/' vs '-_'), and a 2-byte input is where a
|
|
|
|
|
// padding-emitting encoder would append '='.
|
|
|
|
|
const std::string enc = logos::b64UrlEncode({0xFB, 0xFF});
|
|
|
|
|
|
|
|
|
|
EXPECT_EQ(enc.find('+'), std::string::npos);
|
|
|
|
|
EXPECT_EQ(enc.find('/'), std::string::npos);
|
|
|
|
|
EXPECT_EQ(enc.find('='), std::string::npos);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
TEST(CodecBytes, TailGroupsOfEveryLengthSurvive)
|
|
|
|
|
{
|
|
|
|
|
for (size_t n = 0; n <= 5; ++n) {
|
|
|
|
|
std::vector<uint8_t> v(n);
|
|
|
|
|
for (size_t i = 0; i < n; ++i) v[i] = static_cast<uint8_t>(0xA0 + i);
|
|
|
|
|
EXPECT_EQ(logos::b64UrlDecode(logos::b64UrlEncode(v)), v) << "length " << n;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
TEST(CodecBytes, EmbeddedNulSurvives)
|
|
|
|
|
{
|
|
|
|
|
// The reason bytes are tagged at all: a plain JSON string cannot carry this.
|
|
|
|
|
const std::vector<uint8_t> v{'a', 0, 'b', 0, 'c'};
|
|
|
|
|
|
|
|
|
|
EXPECT_EQ(logos::b64UrlDecode(logos::b64UrlEncode(v)), v);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
TEST(CodecBytes, BytesToJsonEmitsTheCanonicalTag)
|
|
|
|
|
{
|
|
|
|
|
const nlohmann::json j = logos::bytesToJson({'h', 'i'});
|
|
|
|
|
|
|
|
|
|
ASSERT_TRUE(j.is_object());
|
|
|
|
|
EXPECT_EQ(j.size(), 1u);
|
|
|
|
|
ASSERT_TRUE(j.contains("_bytes"));
|
|
|
|
|
EXPECT_TRUE(j["_bytes"].is_string());
|
|
|
|
|
EXPECT_TRUE(logos::isTaggedBytes(j));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The decoder is deliberately tolerant of padding a peer may have emitted.
|
|
|
|
|
// The cdylib backend used to carry a SECOND decoder that bailed on any
|
|
|
|
|
// non-alphabet character, so padded input silently produced an empty vector —
|
|
|
|
|
// the two disagreed, and this test is what the surviving one must satisfy.
|
|
|
|
|
TEST(CodecBytes, DecodeAcceptsPaddedInput)
|
|
|
|
|
{
|
|
|
|
|
EXPECT_EQ(logos::b64UrlDecode("Zm9vYg=="), (std::vector<uint8_t>{'f', 'o', 'o', 'b'}));
|
|
|
|
|
}
|