Files
logos-cpp-sdk/tests/generator/test_records.cpp
T
Dario Gabriel LipicarandClaude Opus 5 70586290d3 chore(generator): retire ApiStyle::Std
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>
2026-07-30 19:42:29 -03:00

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))"));
}