mirror of
https://github.com/logos-co/logos-cpp-sdk.git
synced 2026-08-31 09:41:06 +00:00
The Std surface — std-typed signatures over a QVariant + LogosAPIClient body — no longer had a caller. `interface: "universal"` modules moved to `lp` (std types over the Qt-free logos-protocol C ABI), and nothing else ever selected it, so every Std branch was dead weight sitting in front of the two live ones. `--api-style=std` is now rejected with a message naming the retirement rather than aliased to `qt`. A stale caller that still passes it wants std signatures; handing it the Qt surface would fail later, further from the cause. The collapse is deliberate about the branches where Std was tested BEFORE Qt, since a naive "delete the block containing ApiStyle::Std" changes Qt output: - makeHeader's include block tested Std first, so its `else` is the Qt include list — the Qt includes are kept and promoted, not deleted. - recordToWireExpr / recordFromWireExpr returned the Qt map form from a guarded `if` and the Std form from the function's trailing `return`. The guard is dropped and the Qt form promoted to the tail; deleting only the trailing return would have left a path falling off the end. - The private-member `else if (!events.isEmpty())` arm reads as an event test but was Std-only; the Qt arm (m_eventReplica + m_eventSource) survives, so setEventSource/trigger still have their storage. - `if (apiStyle == Qt || !events.isEmpty())` is a disjunction, not an Std branch: it unwraps to an unconditional emit, keeping ensureReplica()'s declaration next to its definition. - `isRec || style == Std` loses only the right disjunct — dropping `isRec ||` would double-wrap record fields in QVariant::fromValue. mapParamTypeStd / mapReturnTypeStd / isStdRefType stay: they are the shared std type table that ApiStyle::Lp reaches through the non-Qt arm of paramTypeFor / returnTypeFor / byRefFor and directly from lpPushExpr / lpFromJsonExpr. Verified by output equivalence rather than by the build succeeding: the generator was run over 13 fixture cases (the full_api contract as both a bound interface and a baked dep, three record-bearing contracts incl. map-of-record fields, the chat module's production contract, and a no-events contract) for both qt and lp, before and after. `diff -r` over the 194 resulting files reports no differences, and the experimental --lidl backends are byte-identical too. Test suite: 180/180, unchanged. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
217 lines
9.5 KiB
C++
217 lines
9.5 KiB
C++
// Records on the consumer side of the LEGACY generator — the wrapper every
|
|
// C++ module actually gets for its dependencies (`--dep <name>=<lidl>`).
|
|
//
|
|
// A contract's `type Status { ... }` used to reach every C++ consumer as an
|
|
// untyped bag: QVariant on the Qt surface, LogosMap on the lp one. The
|
|
// caller then had to know the field names AND, for a `bstr` field, that the
|
|
// value arrives as the canonical `{"_bytes": "..."}` envelope it must unwrap
|
|
// itself — while Rust and the client-stub backend hand back a real struct.
|
|
//
|
|
// These assert on generated source text. That the emitted conversions compile
|
|
// and round-trip (bytes tagged at every depth, uint64 above 2^32 intact) is
|
|
// covered by generating a wrapper and building it — see the PR description.
|
|
|
|
#include <gtest/gtest.h>
|
|
#include <QJsonArray>
|
|
#include <QJsonObject>
|
|
#include "generator_lib.h"
|
|
|
|
namespace {
|
|
|
|
QJsonObject field(const char* name, const char* type)
|
|
{
|
|
QJsonObject f;
|
|
f["name"] = name;
|
|
f["type"] = type;
|
|
return f;
|
|
}
|
|
|
|
QJsonObject param(const char* name, const char* type)
|
|
{
|
|
QJsonObject p;
|
|
p["name"] = name;
|
|
p["type"] = type;
|
|
return p;
|
|
}
|
|
|
|
QJsonObject method(const char* name, const char* returnType, const QJsonArray& params = {})
|
|
{
|
|
QJsonObject m;
|
|
m["name"] = name;
|
|
m["returnType"] = returnType;
|
|
m["isInvokable"] = true;
|
|
m["parameters"] = params;
|
|
return m;
|
|
}
|
|
|
|
// `type Status { port: uint, blob: bstr }` plus a record that nests it in
|
|
// both container shapes.
|
|
QJsonArray statusRecords()
|
|
{
|
|
QJsonObject status;
|
|
status["name"] = "Status";
|
|
status["fields"] = QJsonArray{field("port", "qulonglong"), field("blob", "QByteArray")};
|
|
|
|
QJsonObject batch;
|
|
batch["name"] = "Batch";
|
|
batch["fields"] = QJsonArray{field("label", "QString"),
|
|
field("items", "QList<Status>"),
|
|
field("tags", "QMap<QString, Status>")};
|
|
return QJsonArray{status, batch};
|
|
}
|
|
|
|
QJsonArray statusMethods()
|
|
{
|
|
return QJsonArray{
|
|
method("getStatus", "Status"),
|
|
method("describeStatus", "QString", QJsonArray{param("s", "Status")}),
|
|
method("listStatuses", "QList<Status>"),
|
|
method("getBatch", "Batch"),
|
|
};
|
|
}
|
|
|
|
} // namespace
|
|
|
|
// The Qt surface: a struct nested in the wrapper class, typed accessors, and
|
|
// no QVariant anywhere a record is named.
|
|
TEST(Records, QtWrapperExposesTheStruct)
|
|
{
|
|
const QString h = makeHeader("info_module", "InfoModule", statusMethods(),
|
|
ApiStyle::Qt, {}, BindMode::Static, statusRecords());
|
|
|
|
// Nested, so two deps may each declare a `Status` in one consumer.
|
|
EXPECT_TRUE(h.contains(" struct Status {"));
|
|
EXPECT_TRUE(h.contains(" qulonglong port{};"));
|
|
EXPECT_TRUE(h.contains(" QByteArray blob{};"));
|
|
|
|
// Containers of records keep their element type.
|
|
EXPECT_TRUE(h.contains(" QList<Status> items{};"));
|
|
EXPECT_TRUE(h.contains(" QMap<QString, Status> tags{};"));
|
|
|
|
EXPECT_TRUE(h.contains("Status getStatus(logos::CallError* err = nullptr);"));
|
|
EXPECT_TRUE(h.contains("QString describeStatus(const Status& s,"));
|
|
EXPECT_TRUE(h.contains("QList<Status> listStatuses("));
|
|
|
|
// The old fallback is gone.
|
|
EXPECT_FALSE(h.contains("QVariant getStatus("));
|
|
EXPECT_FALSE(h.contains("describeStatus(QVariant"));
|
|
}
|
|
|
|
// The lp surface spells the same records in std types — a universal
|
|
// (Qt-free) module never sees a Qt name.
|
|
TEST(Records, LpWrapperUsesStdFieldTypes)
|
|
{
|
|
for (ApiStyle style : {ApiStyle::Lp}) {
|
|
const QString h = makeHeader("info_module", "InfoModule", statusMethods(),
|
|
style, {}, BindMode::Static, statusRecords());
|
|
EXPECT_TRUE(h.contains(" uint64_t port{};")) << h.toStdString();
|
|
EXPECT_TRUE(h.contains(" std::vector<uint8_t> blob{};"));
|
|
EXPECT_TRUE(h.contains(" std::vector<Status> items{};"));
|
|
EXPECT_TRUE(h.contains(" std::map<std::string, Status> tags{};"));
|
|
EXPECT_TRUE(h.contains("std::vector<Status> listStatuses("));
|
|
// No LogosMap stand-in for a record.
|
|
EXPECT_FALSE(h.contains("LogosMap getStatus("));
|
|
}
|
|
|
|
// std::map needs its header on the lp surface.
|
|
const QString lp = makeHeader("info_module", "InfoModule", statusMethods(),
|
|
ApiStyle::Lp, {}, BindMode::Static, statusRecords());
|
|
EXPECT_TRUE(lp.contains("#include <map>"));
|
|
}
|
|
|
|
// A `bstr` field must ride the canonical tagged form at any depth — the
|
|
// defect class that made a record-as-LogosMap actively wrong rather than
|
|
// merely inconvenient.
|
|
TEST(Records, BytesFieldsUseTheCanonicalEncoding)
|
|
{
|
|
const QString lp = makeSource("info_module", "InfoModule", "info_module_api.h",
|
|
statusMethods(), ApiStyle::Lp, {}, BindMode::Static,
|
|
statusRecords());
|
|
EXPECT_TRUE(lp.contains("__j[\"blob\"] = logos::bytesToJson(v.blob);"));
|
|
EXPECT_TRUE(lp.contains("__out.blob = logos::jsonToBytes(w.at(\"blob\"));"));
|
|
|
|
const QString qt = makeSource("info_module", "InfoModule", "info_module_api.h",
|
|
statusMethods(), ApiStyle::Qt, {}, BindMode::Static,
|
|
statusRecords());
|
|
EXPECT_TRUE(qt.contains("__out.blob = __m.value(QStringLiteral(\"blob\")).toByteArray();"));
|
|
}
|
|
|
|
// The conversions are file-local statics in the .cpp: an lp consumer's own
|
|
// translation units must not need the wire type to include the header.
|
|
TEST(Records, ConversionsStayOutOfTheHeader)
|
|
{
|
|
const QString h = makeHeader("info_module", "InfoModule", statusMethods(),
|
|
ApiStyle::Lp, {}, BindMode::Static, statusRecords());
|
|
EXPECT_FALSE(h.contains("recToWire_Status"));
|
|
|
|
const QString c = makeSource("info_module", "InfoModule", "info_module_api.h",
|
|
statusMethods(), ApiStyle::Lp, {}, BindMode::Static,
|
|
statusRecords());
|
|
EXPECT_TRUE(c.contains("static nlohmann::json recToWire_Status(const InfoModule::Status& v);"));
|
|
EXPECT_TRUE(c.contains("static InfoModule::Status recFromWire_Status(const nlohmann::json& w);"));
|
|
// Declared before defined, so records may reference each other in any order.
|
|
EXPECT_LT(c.indexOf("static nlohmann::json recToWire_Batch(const InfoModule::Batch& v);"),
|
|
c.indexOf("static nlohmann::json recToWire_Status(const InfoModule::Status& v) {"));
|
|
}
|
|
|
|
// A return type written before the `Class::` of a definition is outside class
|
|
// scope and must be qualified; a parameter is inside it and must not be
|
|
// (an unqualified return type simply does not compile).
|
|
TEST(Records, ReturnTypesAreQualifiedInTheDefinition)
|
|
{
|
|
const QString c = makeSource("info_module", "InfoModule", "info_module_api.h",
|
|
statusMethods(), ApiStyle::Qt, {}, BindMode::Static,
|
|
statusRecords());
|
|
EXPECT_TRUE(c.contains("InfoModule::Status InfoModule::getStatus("));
|
|
EXPECT_TRUE(c.contains("QList<InfoModule::Status> InfoModule::listStatuses("));
|
|
EXPECT_TRUE(c.contains("QString InfoModule::describeStatus(const Status& s,"));
|
|
}
|
|
|
|
// Container decode lambdas are emitted INSIDE the record decoder, which has
|
|
// its own `__m` / `__j`. Reusing those names made a map-of-records field read
|
|
// from its own uninitialized local — it compiled, with only a warning.
|
|
TEST(Records, ContainerLambdasDoNotShadowTheDecoderLocals)
|
|
{
|
|
for (ApiStyle style : {ApiStyle::Qt, ApiStyle::Lp}) {
|
|
const QString c = makeSource("info_module", "InfoModule", "info_module_api.h",
|
|
statusMethods(), style, {}, BindMode::Static,
|
|
statusRecords());
|
|
// The decoder's own map is `__m` (Qt); a nested lambda must not
|
|
// declare another one.
|
|
EXPECT_FALSE(c.contains("const QVariantMap __m = (__m.value")) << c.toStdString();
|
|
EXPECT_FALSE(c.contains("const nlohmann::json& __j = w.at")) << c.toStdString();
|
|
}
|
|
}
|
|
|
|
// The record path is additive: with no records declared, every byte of the
|
|
// generated output is what it was before.
|
|
TEST(Records, EmptyRecordSetChangesNothing)
|
|
{
|
|
const QJsonArray methods{method("ping", "QString", QJsonArray{param("msg", "QString")})};
|
|
for (ApiStyle style : {ApiStyle::Qt, ApiStyle::Lp}) {
|
|
EXPECT_EQ(makeHeader("m", "M", methods, style, {}, BindMode::Static, {}),
|
|
makeHeader("m", "M", methods, style, {}, BindMode::Static));
|
|
EXPECT_EQ(makeSource("m", "M", "m_api.h", methods, style, {}, BindMode::Static, {}),
|
|
makeSource("m", "M", "m_api.h", methods, style, {}, BindMode::Static));
|
|
}
|
|
}
|
|
|
|
// Records reach event callbacks too — an event payload is as typed as a
|
|
// return value.
|
|
TEST(Records, TypedEventCallbacksTakeTheStruct)
|
|
{
|
|
QJsonObject ev;
|
|
ev["name"] = "statusChanged";
|
|
ev["params"] = QJsonArray{param("s", "Status"), param("at", "qlonglong")};
|
|
const QJsonArray events{ev};
|
|
|
|
const QString h = makeHeader("info_module", "InfoModule", statusMethods(),
|
|
ApiStyle::Qt, events, BindMode::Static, statusRecords());
|
|
EXPECT_TRUE(h.contains("bool onStatusChanged(std::function<void(const Status& s, qlonglong at)> callback);"));
|
|
|
|
const QString c = makeSource("info_module", "InfoModule", "info_module_api.h",
|
|
statusMethods(), ApiStyle::Qt, events, BindMode::Static,
|
|
statusRecords());
|
|
EXPECT_TRUE(c.contains("recFromWire_Status(_args.at(0))"));
|
|
}
|