diff --git a/cpp/logos_json_convert.cpp b/cpp/logos_json_convert.cpp index 081c723..9912262 100644 --- a/cpp/logos_json_convert.cpp +++ b/cpp/logos_json_convert.cpp @@ -158,6 +158,22 @@ QVariant nlohmannToQVariant(const nlohmann::json& j) return QVariant(); } +LogosResult jsonToLogosResult(const nlohmann::json& j) +{ + LogosResult r; + r.success = false; + if (!j.is_object()) return r; + if (j.contains("success") && j["success"].is_boolean()) + r.success = j["success"].get(); + // Both payload fields recurse through the canonical decoder, so a `value` + // carrying bytes / nested integers / containers comes back with the same + // shape qvariantToNlohmann sent, and a null `error` stays an invalid + // QVariant rather than becoming an empty QString. + if (j.contains("value")) r.value = nlohmannToQVariant(j["value"]); + if (j.contains("error")) r.error = nlohmannToQVariant(j["error"]); + return r; +} + QVariantList nlohmannArgsToQVariantList(const nlohmann::json& args) { QVariantList result; diff --git a/cpp/logos_json_convert.h b/cpp/logos_json_convert.h index 813d75c..9903072 100644 --- a/cpp/logos_json_convert.h +++ b/cpp/logos_json_convert.h @@ -8,6 +8,8 @@ #include #include +#include "logos_types.h" // LogosResult + struct LogosMethodMetadata { std::string name; std::string signature; @@ -20,6 +22,24 @@ namespace logos { nlohmann::json qvariantToNlohmann(const QVariant& v); QVariant nlohmannToQVariant(const nlohmann::json& j); + +// The inverse of the LogosResult branch inside qvariantToNlohmann. +// +// Without it the pair is asymmetric: qvariantToNlohmann OWNS +// LogosResult -> {success,value,error}, but the return direction only had +// nlohmannToQVariant, which turns that object into a plain QVariantMap — and +// `qvariant_cast` of a QVariantMap yields a default-constructed +// (success=false) result, silently. Any consumer that receives a `result` over +// the canonical JSON wire needs this, so it lives beside its inverse rather +// than being re-derived per code generator. +// +// `error` is decoded through nlohmannToQVariant so a JSON null stays an INVALID +// QVariant — matching what the Qt transport delivers for "no error" and what +// the encoder above emits. A std::string-typed intermediate cannot represent +// that state, which is why this is the JSON-level inverse and not a +// StdLogosResult hop. +LogosResult jsonToLogosResult(const nlohmann::json& j); + QVariantList nlohmannArgsToQVariantList(const nlohmann::json& args); QJsonArray methodsToJsonArray(const std::vector& methods); diff --git a/tests/protocol/CMakeLists.txt b/tests/protocol/CMakeLists.txt index e2b6fe1..39871bf 100644 --- a/tests/protocol/CMakeLists.txt +++ b/tests/protocol/CMakeLists.txt @@ -5,6 +5,11 @@ add_executable(protocol_tests # New protocol-level tests test_protocol_version.cpp test_json_convert_bytes.cpp + # The `result` half of the canonical converter pair: qvariantToNlohmann has + # always owned LogosResult -> JSON; jsonToLogosResult is its inverse, and + # the state it exists to protect is an ABSENT error (which no + # std::string-typed intermediate can carry). + test_json_convert_result.cpp test_universal_provider_dispatch.cpp # Event payload fidelity across the universal -> Qt bridge # (setEventListenerStdBridge). Every Qt-free provider emits through that one diff --git a/tests/protocol/test_json_convert_result.cpp b/tests/protocol/test_json_convert_result.cpp new file mode 100644 index 0000000..dbeb72f --- /dev/null +++ b/tests/protocol/test_json_convert_result.cpp @@ -0,0 +1,101 @@ +#include + +#include "logos_json_convert.h" +#include "logos_types.h" + +#include +#include +#include +#include + +// `result` is the one LIDL type whose canonical converter pair used to be +// ONE-WAY: qvariantToNlohmann owned LogosResult -> {success,value,error}, +// but nothing owned the way back, so every consumer that needed it either +// re-derived it or silently lost the result (a `qvariant_cast` +// of the decoded QVariantMap default-constructs to success=false). +// +// These tests pin the inverse and, in particular, the two states a +// std::string-typed intermediate cannot represent: an ABSENT error, and a +// `value` whose shape must survive (bytes / 64-bit integers / containers). + +using logos::qvariantToNlohmann; +using logos::nlohmannToQVariant; +using logos::jsonToLogosResult; + +namespace { +LogosResult makeResult(bool success, QVariant value, QVariant error) +{ + LogosResult r; + r.success = success; + r.value = std::move(value); + r.error = std::move(error); + return r; +} +} // namespace + +TEST(JsonConvertResult, RoundTripsSuccessWithAbsentError) +{ + qRegisterMetaType("LogosResult"); + + QVariantMap payload; + payload.insert("id", QStringLiteral("abc")); + payload.insert("count", QVariant(qlonglong(42))); + + const LogosResult original = makeResult(true, QVariant(payload), QVariant()); + const nlohmann::json j = qvariantToNlohmann(QVariant::fromValue(original)); + + ASSERT_TRUE(j.is_object()); + EXPECT_TRUE(j["success"].get()); + EXPECT_TRUE(j["error"].is_null()); + + const LogosResult back = jsonToLogosResult(j); + EXPECT_TRUE(back.success); + // The absence of an error must survive: an empty QString here would make + // `error.isValid()` true and change what every caller sees. + EXPECT_FALSE(back.error.isValid()); + ASSERT_TRUE(back.value.canConvert()); + const QVariantMap m = back.value.toMap(); + EXPECT_EQ(m.value("id").toString(), QStringLiteral("abc")); + EXPECT_EQ(m.value("count").toLongLong(), 42); +} + +TEST(JsonConvertResult, RoundTripsFailureWithErrorString) +{ + qRegisterMetaType("LogosResult"); + + const LogosResult original = + makeResult(false, QVariant(), QVariant(QStringLiteral("boom"))); + const nlohmann::json j = qvariantToNlohmann(QVariant::fromValue(original)); + + const LogosResult back = jsonToLogosResult(j); + EXPECT_FALSE(back.success); + EXPECT_EQ(back.error.toString(), QStringLiteral("boom")); + EXPECT_FALSE(back.value.isValid()); +} + +TEST(JsonConvertResult, ValueKeepsBytesAndUint64) +{ + qRegisterMetaType("LogosResult"); + + QVariantMap payload; + payload.insert("blob", QVariant(QByteArray("\x00\x80\xff", 3))); + payload.insert("big", QVariant(qulonglong(18446744073709551615ULL))); + + const LogosResult original = makeResult(true, QVariant(payload), QVariant()); + const LogosResult back = jsonToLogosResult(qvariantToNlohmann(QVariant::fromValue(original))); + + const QVariantMap m = back.value.toMap(); + // Bytes stay bytes (tagged form decoded back to a QByteArray), not a + // base64 string and not a one-key map. + ASSERT_EQ(m.value("blob").userType(), int(QMetaType::QByteArray)); + EXPECT_EQ(m.value("blob").toByteArray().size(), 3); + EXPECT_EQ(m.value("big").toULongLong(), 18446744073709551615ULL); +} + +TEST(JsonConvertResult, NonObjectDecodesToFailure) +{ + const LogosResult back = jsonToLogosResult(nlohmann::json("not-a-result")); + EXPECT_FALSE(back.success); + EXPECT_FALSE(back.value.isValid()); + EXPECT_FALSE(back.error.isValid()); +}