Files
logos-cpp-sdk/tests/experimental/test_lidl_gen_client.cpp
Dario LipicarandClaude Opus 5 acea0d24e2 feat(codegen): a lossless Qt type mapping, and LIDL types in getMethods (#149)
* feat(codegen): a lossless Qt type mapping — typed containers and optionals

`lidlTypeToQt` answered four different LIDL types with one Qt name. `[uint]`,
`[bstr]`, `[[uint]]` and `[any]` were all QVariantList; `{tstr: uint}` and
`{tstr: any}` were both QVariantMap; every `?T` was a bare QVariant. A Qt
consumer therefore lost, on the SAME contract, types that the std consumer next
door kept — it could not tell `?tstr` from `?uint`, and got no compile-time
check on any element.

The table is now recursive:

    [T]                     QList<qtOf(T)>          ([tstr] stays QStringList)
    {tstr: V}               QMap<QString, qtOf(V)>
    ?T                      std::optional<qtOf(T)>  (through optionalValueType,
                                                     so ??T stays two-state)
    any                     QVariant                 — KEPT, deliberately

`any` is the one row that must not widen: QVariant is the only Qt type that
holds bytes AND an exact uint64 AND arbitrary nesting at once, so every
narrower spelling would lose what it was chosen to carry. The rule is applied
at the LEAF, so anything whose element type bottoms out at `any` keeps the
QVariant-family spelling at every depth — `[any]` is QVariantList, `[[any]]`
still is, `{tstr: [any]}` is QVariantMap, `?any` is QVariant.

THE TRAP, and why this is not just a rename. A widened name must never reach
QVariant::fromValue / qvariant_cast / logos::qt::toWire as a WHOLE value.
logos-protocol's qvariantToNlohmann matches a CLOSED userType() set:
QList<qulonglong> is in none of it, so it serialises to JSON null. The decode
fails just as quietly — qvariant_cast<QList<qulonglong>> of a QVariantList
yields an EMPTY list. Neither direction warns. So every widened slot is encoded
and decoded by a generator-emitted ELEMENT LOOP, the shape the record cases
already used, and `lidlQtNeedsElementLoop` is the single predicate that decides
which slots need one.

The emitted loops take their source as a lambda PARAMETER, not a body-local
binding. They nest (`[[uint]]`), every level wants the same short names, and a
local — or a range-for over a name the loop itself declares — is then
self-referential: it compiles and reads uninitialised memory. Measured: three
round-trip tests died on SIGTRAP before the argument form.

THE STRING-KEYED EMITTER IS FROZEN, ON PURPOSE. generator_lib is keyed on flat
type NAMES (lidl_to_json flattens the contract before it gets there, because
that emitter also serves the metaobject-introspection path), so it cannot
derive the levels an element loop needs without parsing C++ type names back
into a tree. Every widened spelling is folded back to the name it produced
before (legacyQtBase), which keeps BOTH surfaces it feeds byte-for-byte
unchanged: the legacy Qt consumer, and the Qt-free lp one whose table is
DERIVED from it through mapParamTypeStd. Verified by generating a
28-method contract through both before and after: the diff is empty. The
widened types are spent in the TypeExpr-driven emitters instead
(lidl_gen_client.cpp here, lidl_gen_qt_consumer.cpp in logos-qt-sdk).

Also here, because both are consequences of the table becoming recursive:

  * lidlTypeToQt gained a record-name HOOK. A wrapper nests its record structs
    in the wrapper class, so a type written outside that scope must qualify
    them — and the emitters used to do that by matching the three shapes that
    could mention a record on the finished string. `?Point` and
    `QList<QList<Point>>` are now spellable, so the qualification happens
    during the walk, at the one place that knows a name is a record.
  * lidlTypeToLidlText — the LIDL contract spelling of a type. Unused here; the
    commit that follows puts getMethods() on it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(cdylib): getMethods publishes the LIDL contract vocabulary, not Qt names

A module's published metadata — `returnType`, `parameters[].type`, `signature`
— answered in Qt type names. Two things wrong with that, and the second is the
one that matters:

  * a cdylib module is Qt-FREE. It described itself in the types of a language
    it does not use, to readers (`lm`, logoscore's method listing, basecamp's
    module inspector) that are showing a human what the module offers.
  * it was LOSSY. `[uint]`, `[bstr]` and `[any]` are three different LIDL types
    and all three published as the single word QVariantList, so the listing
    could not be read back as a contract. That is now `[uint]`, `[bstr]`,
    `[any]`; `{tstr: uint}`; `? tstr`; and a record publishes its declared
    name.

WHY THIS IS SAFE — checked, not assumed. The historical objection is recorded
in the function this replaces: these strings are read as METATYPES, and
emitting a record's struct name here once made the host SIGSEGV. Nothing in the
current runtime does that. logos-plugin-qt's QtProviderObject dispatches on
`method.returnMetaType()` / `parameterMetaType(i)` — the QMetaObject, never
this JSON — and every remaining reader treats these fields as opaque text:
logos-module's `lm` prints them, logoscore's output.cpp prints them, basecamp's
CoreModuleManager forwards the JSON to QML, and the plain wire's json_mapping
only round-trips them. Nothing anywhere builds a QMetaObject from this
metadata.

The spelling comes from lidlTypeToLidlText, which mirrors logos-lidl's
serializeTypeExpr. It is a COPY, because that function is file-local to
logos-lidl's serializer.cpp and the public headers expose no type printer —
so instead of hoping, the pairing is ASSERTED: the test round-trips each shape
through `lidl::serialize` and reads the type text back out of the emitted
`.lidl`. When logos-lidl exports a printer, delete the copy and call it.

Not fixed by this, and not attempted: the Rust SDK's provider generator has its
own `qt_type_name` writing the same JSON, so the two languages now disagree
about how a module describes itself. That is a cross-repo change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(doctests): the generator round-trip pins the lossless Qt spellings

`cpp-sdk-generator-roundtrip.test.yaml` is a CI gate
(.github/workflows/doctests.yml), and two of its `expect_contains` were
pinned to the type names the Qt consumer produced BEFORE the lossless
mapping:

    QStringList labels(const QVariantList& ids
    QVariant nearest(const Point& p, QVariant limit

The generator now emits `QList<qulonglong>` and
`std::optional<Point>` / `std::optional<qulonglong>` for those slots, so both
assertions failed. The `nearest` step's `run` grep was pinned the same way
(`QVariant nearest`), so the line it was supposed to assert on was not even
in the output being searched.

Verified by running the spec's own steps against the generator built from
this commit: 10 run-steps, 0 failures. The `[uint]` -> QList<qulonglong> and
`?T` -> std::optional<T> lines were read out of the real
`consumer/sensor_module_api.h` and `geometry/geometry_module_api.h`, not
written from the mapping table.

Prose too, in three places that described the old table: the Flow-3 type
mapping ("other arrays -> QVariantList"), the composite-types intro
("optionals ... stay QVariantMap / QVariant"), and the composite-signature
step. They now say what the mapping actually is — one LIDL type, one C++
spelling, with `any` the single deliberate exception — and `nearest` is
called out as the one signature carrying both halves of the optional
mapping.

`doctests/outputs/cpp-sdk-generator-roundtrip.md` carries the same prose
corrections. That tree is hand-pinned and CI never diffs it, which is
exactly why it must be corrected by hand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(generator): the consumer wrapper comes from the contract, not from getMethods

`logos-cpp-generator <plugin> --module-only` — the invocation
logos-plugin-qt's generate-module-headers.sh makes for every module's lp
wrapper — built that wrapper's whole type surface out of the plugin's
PUBLISHED `getMethods()` metadata. It now builds it out of the module's `.lidl`
contract, the file the same invocation already passes as `--events-from`.

WHY THIS IS A DEFECT AND NOT A PREFERENCE. generator_lib is keyed on flat type
NAMES, and mapParamType / mapReturnType fall back to QVariant for a name they
do not recognise (generator_lib.cpp:142 and :153). So the wrapper's types
depend on the VOCABULARY a module happens to publish its metadata in, and a
vocabulary this emitter has no row for degrades to QVariant — LogosMap on the
lp surface — with no diagnostic at any layer. It is a machine reader of a
listing that every other consumer treats as human-facing text, and it fails
silently.

It was measured, not theorised. 621772a made the cdylib backend publish the
LIDL contract vocabulary (`tstr`, `[uint]`, `result`, `? tstr`) in place of Qt
type names, because that listing is what `lm`, logoscore and basecamp show a
human and Qt names are the wrong answer for a Qt-free module. Every
`interface: "universal"` module's lp wrapper collapsed:
logos-test-modules' `checks.unit-tests-new-api` went PASS -> FAIL, and the
compiler said exactly why —

    error: no viable conversion from 'LogosMap' to 'StdLogosResult'
        StdLogosResult r = modules().test_basic_module.resultWithMap();

`result` is not a name mapReturnType knows, so it became QVariant, so it became
LogosMap. Bisected to exactly 621772a (5ffd90b passes, dd52d9d fails).

THE FIX IS TO STOP READING THAT VOCABULARY, not to learn a second one.
`int` means a 32-bit Qt int in one table and a 64-bit LIDL integer in the
other, and the reader cannot tell from the string which table it is holding —
a merged table would silently mistype every integer on every module. The
contract has no such ambiguity: it is a TypeExpr tree, and lidl_to_json is the
single place it is flattened. Taking methods from it makes this path emit the
same wrapper as `--general-only --dep <name>=<name>.lidl`, which is what
buildHeaders.nix already runs under cross-compilation and for the entire Qt
surface. Contract-first, on every platform, for every surface.

WHAT CHANGED, exactly:

  * loadEventsFromLidl -> loadContractFromLidl. It already parsed the whole
    contract and threw the methods away; it now returns them, after the same
    lidlCheckRecords + lidlInjectIdentity + noteOptionalPositionalSlots that
    main.cpp's --dep path applies. Identity is injected rather than read,
    matching the provider side (main.cpp's --backend cdylib), so the two cannot
    disagree about name() / version().
  * A sidecar that is NAMED BUT MISSING is now refused (exit 2), and an
    unreadable or malformed one is fatal (exit 4). Both used to be shrugged off
    — which shipped a wrapper with no typed events, and would now ship one with
    no typed methods, in the silently-empty shape generate-module-headers.sh
    exists to refuse.
  * The plugin is STILL LOADED. That load is the dlopen check this path
    performs (exit 3 on an SDK/ABI skew) and it is unchanged; what the plugin
    says about itself is now compared against the contract instead of believed,
    and a divergence — a stale sidecar — is reported by name on stderr. Only
    `isInvokable` entries are compared: a cdylib publishes its events into the
    same array, tagged `"type": "event"`, and both emitters already skip those.
  * A module with NO contract keeps introspection — a handcrafted Qt plugin's
    QMetaObject is still the only description of its API that exists, and Qt
    type names are the right vocabulary for it — but a listing spelled in the
    LIDL vocabulary with no contract to go with it is now REFUSED (exit 7)
    instead of silently producing the untyped wrapper. That combination is only
    reachable by hand: buildHeaders.nix always passes the flag when the sidecar
    exists, and it is the shape the developer guide used to suggest. The two
    vocabularies are not distinguishable in general, which is the whole reason
    this emitter must read only one — but they do not have to be: the words
    they share (`int`, `bool`) are all in the known table and never reach the
    fallback, so the check keys on the LIDL half Qt has no word for at all
    (`tstr`, `bstr`, `uint`, `float64`, `result`, `any`, and anything starting
    `[`, `{` or `?`). No Qt type is spelled that way, so it cannot false-fire;
    a false negative is just the old behaviour.

THE ENUMERATION, because two previous ones missed this reader. Searching for
who greps `returnType` is what missed it; the question is what the data FLOWS
INTO. Every consumer of a published getMethods array in the workspace:

  MACHINE (one, and it is this one)
    logos-cpp-sdk cpp-generator/plugin_introspect.cpp, reached only through
    logos-plugin-qt's generate-module-headers.sh / buildHeaders.nix.

  HUMAN-READABLE OR OPAQUE PASSTHROUGH (all of them)
    logos-module's `lm` (prints; --json re-emits verbatim), logoscore-cli's
    client/output.cpp (prints) and core_service_dispatch.cpp (forwards),
    logos-logoscore-tui (formats one line per method), logos-module-viewer
    (reads the QMetaObject directly, not this JSON), basecamp's
    CoreModuleManager / MainUIBackend (hands the JSON string to QML),
    logos-protocol's json_mapping.cpp and qvariant_rpc_value.cpp (round-trip
    the strings unread).

  PRODUCERS, for completeness: lidl_gen_cdylib.cpp (LIDL vocabulary),
    logos-plugin-qt's QtProviderObject (Qt names, from the QMetaObject) and
    lidl_gen_cdylib_glue.cpp (forwards the cdylib's), logos-rust-sdk's
    rustgen_provider.rs (still Qt names — the two languages disagree, as
    621772a noted), and logos-protocol's ModuleProxy, which appends derived
    name()/version() entries spelled `QString`. None of that reaches a type
    decision any more, which is the point of the change.

  Build-system paths checked and clear: `<plugin> --module-only` is invoked
    from exactly one place in the workspace (generate-module-headers.sh:60);
    LogosModule.cmake, buildPlugin.nix and mkLogosModuleTests.nix all use
    `--general-only`, which is contract-driven already; the doctests' `--lidl
    --module-only` is a different mode entirely.

VERIFIED.

`nix build path:./repos/logos-test-modules#checks.aarch64-darwin.unit-tests-new-api`
with this SDK overridden in (plus the logos-lidl overrides the branch needs at
the qt-sdk and plugin-qt nodes) — 32 passed, 0 failed. The same command against
this branch's HEAD fails to compile, as above. The build log shows the path
taken, per module:

  Detected new-API plugin (LogosProviderPlugin), using getMethods() — 43 methods
  Using the module's LIDL contract for the method surface — 41 methods
      (the plugin's published listing is a description, not a type source)

The refusal, measured by hand against a real LIDL-publishing plugin
(test_basic_module, built from this branch) because no check exercises a
hand-run invocation:

  no --events-from   -> exit 7, nothing written, the message above naming
                        8 offending slots
  with --events-from -> exit 0, 41 typed methods, 69 `std::string` in the
                        emitted lp header
  a pre-621772a build of the SAME module (Qt-name listing), no --events-from
                     -> exit 0, still generates, still typed — the refusal does
                        not fire on the vocabulary this emitter can read

nix/tests-generator-cli.nix gains the two CLI-surface cases this adds: a
`--events-from` naming a file that does not exist is refused with that
sentence, and — the control that makes it mean something — the same command
with a READABLE contract gets past the flag and fails on the plugin instead. No
plugin is needed for either: the contract is loaded before the plugin is
opened.

logos-cpp-sdk's own checks (tests, generator-cli, module-impl-abi): 334 of 334.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 18:17:44 -03:00

559 lines
21 KiB
C++

#include <gtest/gtest.h>
#include "lidl_gen_client.h"
static ModuleDecl makeTestModule()
{
ModuleDecl m;
m.name = "wallet_module";
m.version = "1.0.0";
m.description = "Wallet";
m.category = "finance";
m.depends.push_back("crypto");
{
MethodDecl md;
md.name = "createAccount";
md.returnType = { TypeExpr::Primitive, "tstr", {} };
ParamDecl p; p.name = "passphrase"; p.type = { TypeExpr::Primitive, "tstr", {} };
md.params.push_back(p);
m.methods.push_back(md);
}
{
MethodDecl md;
md.name = "getBalance";
md.returnType = { TypeExpr::Primitive, "uint", {} };
ParamDecl p; p.name = "address"; p.type = { TypeExpr::Primitive, "tstr", {} };
md.params.push_back(p);
m.methods.push_back(md);
}
{
MethodDecl md;
md.name = "listAccounts";
TypeExpr elem = { TypeExpr::Primitive, "tstr", {} };
md.returnType = { TypeExpr::Array, "", { elem } };
m.methods.push_back(md);
}
EventDecl ed;
ed.name = "onTransfer";
ParamDecl ep; ep.name = "hash"; ep.type = { TypeExpr::Primitive, "tstr", {} };
ed.params.push_back(ep);
m.events.push_back(ed);
return m;
}
// ---------------------------------------------------------------------------
// Header generation
// ---------------------------------------------------------------------------
TEST(LidlGenClient, HeaderHasClassName)
{
auto m = makeTestModule();
QString h = lidlMakeHeader(m);
EXPECT_TRUE(h.contains("class WalletModule {"));
}
TEST(LidlGenClient, HeaderHasConstructor)
{
auto m = makeTestModule();
QString h = lidlMakeHeader(m);
EXPECT_TRUE(h.contains("explicit WalletModule(LogosAPI* api)"));
}
TEST(LidlGenClient, HeaderHasSyncMethods)
{
auto m = makeTestModule();
QString h = lidlMakeHeader(m);
EXPECT_TRUE(h.contains("QString createAccount("));
EXPECT_TRUE(h.contains("qulonglong getBalance("));
EXPECT_TRUE(h.contains("QStringList listAccounts("));
}
TEST(LidlGenClient, HeaderHasAsyncMethods)
{
auto m = makeTestModule();
QString h = lidlMakeHeader(m);
EXPECT_TRUE(h.contains("void createAccountAsync("));
EXPECT_TRUE(h.contains("void getBalanceAsync("));
EXPECT_TRUE(h.contains("void listAccountsAsync("));
}
TEST(LidlGenClient, HeaderHasEventMethods)
{
auto m = makeTestModule();
QString h = lidlMakeHeader(m);
EXPECT_TRUE(h.contains("bool on(const QString& eventName"));
}
// setEventSource / eventSource / trigger used to be emitted here: an
// author-facing way to SOURCE events through a CONSUMER wrapper. They had zero
// callers anywhere in the workspace, including the vendored SDK copies, and the
// generated code never used them either — m_eventSource was written only by its
// own setter and read only by trigger, so a trigger() call without a prior
// setEventSource() just warned and returned.
//
// Removing them also removes the reason a Qt wrapper had to keep a
// LogosAPIClient alongside the lp client: onEventResponse was the only lp-less
// call left. Pinned so the surface does not quietly reappear.
TEST(LidlGenClient, NoDeadEventSourceSurface)
{
auto m = makeTestModule();
QString h = lidlMakeHeader(m);
EXPECT_FALSE(h.contains("setEventSource")) << h.toStdString();
EXPECT_FALSE(h.contains("m_eventSource")) << h.toStdString();
EXPECT_FALSE(h.contains("trigger(")) << h.toStdString();
}
TEST(LidlGenClient, HeaderHasIncludes)
{
auto m = makeTestModule();
QString h = lidlMakeHeader(m);
EXPECT_TRUE(h.contains("#include \"logos_api.h\""));
EXPECT_TRUE(h.contains("#include \"logos_api_client.h\""));
EXPECT_TRUE(h.contains("#include \"logos_types.h\""));
}
TEST(LidlGenClient, HeaderHasPrivateMembers)
{
auto m = makeTestModule();
QString h = lidlMakeHeader(m);
EXPECT_TRUE(h.contains("LogosAPI* m_api"));
EXPECT_TRUE(h.contains("LogosAPIClient* m_client"));
EXPECT_TRUE(h.contains("QString m_moduleName"));
}
// ---------------------------------------------------------------------------
// Source generation
// ---------------------------------------------------------------------------
TEST(LidlGenClient, SourceHasConstructor)
{
auto m = makeTestModule();
QString s = lidlMakeSource(m);
EXPECT_TRUE(s.contains("WalletModule::WalletModule(LogosAPI* api)"));
EXPECT_TRUE(s.contains("getClient(\"wallet_module\")"));
}
TEST(LidlGenClient, SourceHasSyncImplementations)
{
auto m = makeTestModule();
QString s = lidlMakeSource(m);
EXPECT_TRUE(s.contains("WalletModule::createAccount("));
EXPECT_TRUE(s.contains("invokeRemoteMethod(\"wallet_module\", \"createAccount\""));
}
TEST(LidlGenClient, SourceHasAsyncImplementations)
{
auto m = makeTestModule();
QString s = lidlMakeSource(m);
EXPECT_TRUE(s.contains("WalletModule::createAccountAsync("));
EXPECT_TRUE(s.contains("invokeRemoteMethodAsync(\"wallet_module\", \"createAccount\""));
}
TEST(LidlGenClient, SourceHasEventBoilerplate)
{
auto m = makeTestModule();
QString s = lidlMakeSource(m);
EXPECT_TRUE(s.contains("WalletModule::on(const QString& eventName"));
// Deferred, not a synchronous acquire -- see MakeSourceTest.
EXPECT_TRUE(s.contains("onEventWhenAvailable(m_moduleName, eventName, callback)"));
EXPECT_FALSE(s.contains("ensureReplica"));
}
TEST(LidlGenClient, SourceHasReturnConversion)
{
auto m = makeTestModule();
QString s = lidlMakeSource(m);
// createAccount returns tstr → QString, should use .toString()
EXPECT_TRUE(s.contains("_result.toString()"));
// getBalance returns uint → qulonglong, so the accessor must be the 64-bit
// unsigned one; toInt() truncated and re-signed it.
EXPECT_TRUE(s.contains("_result.toULongLong()"));
// listAccounts returns [tstr] → QStringList, should use .toStringList()
EXPECT_TRUE(s.contains("_result.toStringList()"));
}
// ---------------------------------------------------------------------------
// Metadata JSON generation
// ---------------------------------------------------------------------------
TEST(LidlGenClient, MetadataJson)
{
auto m = makeTestModule();
QString json = lidlGenerateMetadataJson(m);
EXPECT_TRUE(json.contains("\"name\": \"wallet_module\""));
EXPECT_TRUE(json.contains("\"version\": \"1.0.0\""));
EXPECT_TRUE(json.contains("\"category\": \"finance\""));
EXPECT_TRUE(json.contains("\"crypto\""));
}
TEST(LidlGenClient, MetadataJsonDefaults)
{
ModuleDecl m;
m.name = "bare";
QString json = lidlGenerateMetadataJson(m);
EXPECT_TRUE(json.contains("\"version\": \"0.0.0\""));
EXPECT_TRUE(json.contains("\"category\": \"general\""));
}
// ---------------------------------------------------------------------------
// Edge cases
// ---------------------------------------------------------------------------
TEST(LidlGenClient, MethodWithManyParams)
{
ModuleDecl m;
m.name = "multi";
MethodDecl md;
md.name = "bigMethod";
md.returnType = { TypeExpr::Primitive, "tstr", {} };
for (int i = 0; i < 7; ++i) {
ParamDecl p;
p.name = QString("p%1").arg(i).toStdString();
p.type = { TypeExpr::Primitive, "tstr", {} };
md.params.push_back(p);
}
m.methods.push_back(md);
QString s = lidlMakeSource(m);
// Args are packed via packVariantList (one QVariant element per arg),
// regardless of arity — never a braced/`<<` list that would spread a
// QVariantList-typed arg.
EXPECT_TRUE(s.contains("packVariantList(p0, p1, p2, p3, p4, p5, p6)"));
}
// Regression: a QVariantList-typed ([any]/[int]/...) argument must be packed as
// ONE element, not concatenated into the args list. `QVariantList{v}` and
// `QVariantList() << v` both spread a QVariantList; packVariantList wraps each
// arg with QVariant::fromValue, so a single list arg stays a single arg.
TEST(LidlGenClient, ListArgIsPackedAsOneElement)
{
ModuleDecl m;
m.name = "arrs";
MethodDecl md;
md.name = "echoList";
TypeExpr elem = { TypeExpr::Primitive, "any", {} };
md.returnType = { TypeExpr::Array, "", { elem } };
ParamDecl p;
p.name = "v";
p.type = { TypeExpr::Array, "", { elem } };
md.params.push_back(p);
m.methods.push_back(md);
QString s = lidlMakeSource(m);
// Both sync and async pack the single list arg via packVariantList(v).
EXPECT_TRUE(s.contains("invokeRemoteMethod(\"arrs\", \"echoList\", packVariantList(v)"));
EXPECT_TRUE(s.contains("invokeRemoteMethodAsync(\"arrs\", \"echoList\", packVariantList(v)"));
// Guard against the spreading forms regressing back in.
EXPECT_FALSE(s.contains("QVariantList{v}"));
EXPECT_FALSE(s.contains("QVariantList() << v"));
}
TEST(LidlGenClient, VoidReturnMethod)
{
ModuleDecl m;
m.name = "test";
MethodDecl md;
md.name = "doStuff";
md.returnType = { TypeExpr::Primitive, "void", {} };
m.methods.push_back(md);
QString h = lidlMakeHeader(m);
// void return should have async callback with void()
EXPECT_TRUE(h.contains("std::function<void()>"));
QString s = lidlMakeSource(m);
// sync void method should not have "QVariant _result ="
// The source should just call the method without capturing return
EXPECT_FALSE(s.contains("QVariant _result = m_client->invokeRemoteMethod(\"test\", \"doStuff\""));
}
// Records: a `type` decl becomes a real C++ struct in the generated header, so a
// Qt consumer says `Status s = client.makeStatus();` rather than digging fields
// out of a QVariantMap. Additive — nothing generated records before this.
static ModuleDecl makeRecordModule()
{
ModuleDecl m;
m.name = "info_module";
m.version = "1.0.0";
TypeDecl rec;
rec.name = "Status";
FieldDecl a; a.name = "port"; a.type = { TypeExpr::Primitive, "uint", {} };
FieldDecl b; b.name = "blob"; b.type = { TypeExpr::Primitive, "bstr", {} };
rec.fields = {a, b};
m.types.push_back(rec);
{
MethodDecl md;
md.name = "describeStatus";
md.returnType = { TypeExpr::Primitive, "tstr", {} };
ParamDecl p; p.name = "s"; p.type = { TypeExpr::Named, "Status", {} };
md.params.push_back(p);
m.methods.push_back(md);
}
{
MethodDecl md;
md.name = "makeStatuses";
TypeExpr elem = { TypeExpr::Named, "Status", {} };
md.returnType = { TypeExpr::Array, "", { elem } };
m.methods.push_back(md);
}
return m;
}
// `isTaggedBytes()` is checked BEFORE `is_object()` in both the codec and the
// QVariant bridge, so a record whose only field is a tstr named `_bytes` is
// wire-identical to a tagged byte string and decodes as bytes — the struct
// silently disappears. The ambiguity is inherent to the tagged form; refusing
// to emit the one shape guaranteed to misdecode is what a generator can do
// about it.
TEST(LidlGenClient, RecordThatCollidesWithTheBytesTagIsRefused)
{
ModuleDecl m;
m.name = "c_module";
TypeDecl bad;
bad.name = "Sneaky";
FieldDecl f; f.name = "_bytes"; f.type = { TypeExpr::Primitive, "tstr", {} };
bad.fields = {f};
m.types.push_back(bad);
QString err;
EXPECT_FALSE(lidlCheckRecords(m, &err));
EXPECT_TRUE(err.contains("Sneaky")) << err.toStdString();
EXPECT_TRUE(err.contains("_bytes")) << err.toStdString();
// A SECOND field disambiguates it — isTaggedBytes requires exactly one key,
// so this shape round-trips and must still be allowed.
FieldDecl g; g.name = "other"; g.type = { TypeExpr::Primitive, "int", {} };
m.types[0].fields.push_back(g);
EXPECT_TRUE(lidlCheckRecords(m, nullptr));
// A `_bytes` field that is not the only one, and a differently-named sole
// field, are both fine.
ModuleDecl ok;
ok.name = "ok_module";
TypeDecl t; t.name = "Fine";
FieldDecl h; h.name = "payload"; h.type = { TypeExpr::Primitive, "tstr", {} };
t.fields = {h};
ok.types.push_back(t);
EXPECT_TRUE(lidlCheckRecords(ok, nullptr));
}
// The ASYNC overload must decode a record the same way the sync one does.
//
// `qvariant_cast<Status>(v)` does not fail on the wire's QVariantMap: no
// Q_DECLARE_METATYPE is emitted for the struct, so the cast silently yields a
// DEFAULT-CONSTRUCTED Status and the caller sees empty fields with no
// diagnostic. The sync path was already correct, which makes it worse — the
// same call would be right or wrong depending only on which overload the
// caller reached for.
TEST(LidlGenClient, AsyncRecordReturnsDecodeFieldByField)
{
const QString c = lidlMakeSource(makeRecordModule(), BindMode::Bound);
// A [Record] return, in the async callback.
EXPECT_TRUE(c.contains("StatusFromVariant")) << c.toStdString();
EXPECT_FALSE(c.contains("qvariant_cast<QList<Status>>")) << c.toStdString();
EXPECT_FALSE(c.contains("qvariant_cast<Status>")) << c.toStdString();
}
TEST(LidlGenClient, RecordsBecomeStructsWithConversions)
{
const QString h = lidlMakeHeader(makeRecordModule(), BindMode::Bound);
// The struct, at the 1-1 Qt spellings: 64-bit unsigned, QByteArray for bytes.
EXPECT_TRUE(h.contains("struct Status {")) << h.toStdString();
EXPECT_TRUE(h.contains("qulonglong port{};")) << h.toStdString();
EXPECT_TRUE(h.contains("QByteArray blob{};")) << h.toStdString();
// Conversions both ways.
EXPECT_TRUE(h.contains("inline QVariant StatusToVariant(const Status& v)")) << h.toStdString();
EXPECT_TRUE(h.contains("inline Status StatusFromVariant(const QVariant& value)")) << h.toStdString();
// A bstr field is a QByteArray: logos-protocol's QVariant<->JSON conversion
// already materialises the tagged {"_bytes":…} form as QByteArray, so binary
// survives without record-specific bytes handling.
EXPECT_TRUE(h.contains("__out.blob = __m.value(\"blob\").toByteArray();")) << h.toStdString();
// Methods speak the record: by const& in, typed list out. A QVariantList
// could not hold a Status without Q_DECLARE_METATYPE.
EXPECT_TRUE(h.contains("describeStatus(const Status& s")) << h.toStdString();
EXPECT_TRUE(h.contains("QList<Status> makeStatuses(")) << h.toStdString();
}
// ---------------------------------------------------------------------------
// Optionality
//
// This backend is not on any live build path (real Qt consumers come from the
// legacy interface-wrapper path), so the bar here is CONSISTENCY, not features:
// the two spellings of an optional field must not generate different structs
// from the same declaration.
// ---------------------------------------------------------------------------
static ModuleDecl makeOptionalRecordModule(bool useFlagSpelling)
{
ModuleDecl m;
m.name = "opt_module";
m.version = "1.0.0";
TypeDecl t;
t.name = "Profile";
{
FieldDecl f; f.name = "required"; f.type = { TypeExpr::Primitive, "tstr", {} };
t.fields.push_back(f);
}
{
FieldDecl f;
f.name = "nickname";
if (useFlagSpelling) { // `? nickname: tstr`
f.type = { TypeExpr::Primitive, "tstr", {} };
f.optional = true;
} else { // `nickname: ?tstr`
f.type = { TypeExpr::Optional, "", { { TypeExpr::Primitive, "tstr", {} } } };
}
t.fields.push_back(f);
}
m.types.push_back(t);
MethodDecl md;
md.name = "echoProfile";
md.returnType = { TypeExpr::Named, "Profile", {} };
ParamDecl p; p.name = "v"; p.type = { TypeExpr::Named, "Profile", {} };
md.params.push_back(p);
m.methods.push_back(md);
return m;
}
TEST(LidlGenClient, BothOptionalSpellingsEmitIdenticalCode)
{
const QString flagged = lidlMakeHeader(makeOptionalRecordModule(true), BindMode::Bound);
const QString typed = lidlMakeHeader(makeOptionalRecordModule(false), BindMode::Bound);
// Reading `f.type` alone made the flag spelling emit a bare `QString` — a
// type with no empty inhabitant at all — from the same declaration that the
// type spelling turned into a QVariant.
EXPECT_EQ(flagged, typed) << flagged.toStdString() << "\n---\n" << typed.toStdString();
}
TEST(LidlGenClient, OptionalRecordFieldKeepsItsValueType)
{
const QString h = lidlMakeHeader(makeOptionalRecordModule(true), BindMode::Bound);
// std::optional<QString>, not a bare QVariant. The field is still TWO-state
// — std::nullopt is C++'s single empty inhabitant — but the consumer can
// now see that the value is a string, which is what the std surface next
// door has always told it.
EXPECT_TRUE(h.contains("std::optional<QString> nickname{};")) << h.toStdString();
EXPECT_TRUE(h.contains("QString required{};")) << h.toStdString();
EXPECT_TRUE(h.contains("#include <optional>")) << h.toStdString();
// A record field is a NAMED slot: empty omits the key rather than writing an
// empty value into the map.
EXPECT_TRUE(h.contains("if (v.nickname.has_value())")) << h.toStdString();
// Absent and null both arrive as an invalid QVariant, and the optional
// decode turns exactly that into nullopt. A bare conversion (the
// `.toString()` a required tstr field gets) would have turned "empty" into
// "", which is a VALUE.
EXPECT_TRUE(h.contains("if (!__s.isValid() || __s.isNull()) return std::optional<QString>();"))
<< h.toStdString();
EXPECT_FALSE(h.contains("__out.nickname = __m.value(\"nickname\").toString();"))
<< h.toStdString();
}
// The `_bytes` collision check must read through an optional too. A PRESENT
// `? _bytes: tstr` still encodes to {"_bytes": "..."} — the shape that decodes
// as a byte string and loses the record — so both spellings have to be refused.
// Reading `f.type` refused only the flag one.
TEST(LidlGenClient, BytesTagCollisionIsRefusedThroughAnOptional)
{
auto sneaky = [](bool useFlagSpelling) {
ModuleDecl m;
m.name = "sneaky_module";
TypeDecl t;
t.name = "Sneaky";
FieldDecl f;
f.name = "_bytes";
if (useFlagSpelling) {
f.type = { TypeExpr::Primitive, "tstr", {} };
f.optional = true;
} else {
f.type = { TypeExpr::Optional, "", { { TypeExpr::Primitive, "tstr", {} } } };
}
t.fields.push_back(f);
m.types.push_back(t);
return m;
};
for (bool flag : {true, false}) {
QString error;
EXPECT_FALSE(lidlCheckRecords(sneaky(flag), &error)) << "flagSpelling=" << flag;
EXPECT_TRUE(error.contains("Sneaky")) << error.toStdString();
}
}
// ---------------------------------------------------------------------------
// Sync timeout + result-carrying async
//
// This emitter and cpp-generator/generator_lib.cpp produce the SAME consumer surface
// for the same contract — one is reached from a published `.lidl`, the other
// through the module builder — so the two must agree. tests/generator/
// test_async_result.cpp holds the legacy twin of these assertions.
// ---------------------------------------------------------------------------
TEST(LidlGenClient, SyncTakesBothErrorAndTimeout)
{
auto m = makeTestModule();
QString h = lidlMakeHeader(m);
// Trailing and defaulted, err first — `createAccount(p)` and
// `createAccount(p, &err)` are both unaffected.
EXPECT_TRUE(h.contains("QString createAccount(const QString& passphrase, "
"logos::CallError* err = nullptr, Timeout timeout = Timeout());"));
EXPECT_TRUE(h.contains("QStringList listAccounts(logos::CallError* err = nullptr, "
"Timeout timeout = Timeout());"));
}
TEST(LidlGenClient, SyncBodyForwardsTheCallersTimeout)
{
auto m = makeTestModule();
QString s = lidlMakeSource(m);
EXPECT_TRUE(s.contains("WalletModule::createAccount(const QString& passphrase, "
"logos::CallError* err, Timeout timeout)"));
EXPECT_TRUE(s.contains("), timeout, &_err);"));
EXPECT_FALSE(s.contains("), Timeout(), &_err);"));
}
TEST(LidlGenClient, HeaderDeclaresTheResultCarryingAsyncEntryPoint)
{
auto m = makeTestModule();
QString h = lidlMakeHeader(m);
EXPECT_TRUE(h.contains("#include \"logos_async_result.h\""));
EXPECT_TRUE(h.contains("void createAccountAsyncResult(const QString& passphrase, "
"std::function<void(logos::AsyncResult<QString>)> callback, "
"Timeout timeout = Timeout());"));
EXPECT_TRUE(h.contains("void listAccountsAsyncResult("
"std::function<void(logos::AsyncResult<QStringList>)> callback, "
"Timeout timeout = Timeout());"));
}
TEST(LidlGenClient, ResultCarryingAsyncRoutesToTheCallErrorAwareOverload)
{
auto m = makeTestModule();
QString s = lidlMakeSource(m);
// Two-argument lambda: only AsyncResultErrorCallback is invocable with it.
EXPECT_TRUE(s.contains("[callback](QVariant v, const logos::CallError& _err) {"));
EXPECT_TRUE(s.contains("logos::AsyncResult<QString> _r;"));
EXPECT_TRUE(s.contains("_r.error = _err;"));
EXPECT_TRUE(s.contains("callback(_r);"));
}
TEST(LidlGenClient, ThePlainAsyncEntryPointIsUnchanged)
{
auto m = makeTestModule();
QString h = lidlMakeHeader(m);
EXPECT_TRUE(h.contains("void createAccountAsync(const QString& passphrase, "
"std::function<void(QString)> callback, Timeout timeout = Timeout());"));
QString s = lidlMakeSource(m);
// Still a ONE-argument lambda -> still the value-only transport overload.
EXPECT_TRUE(s.contains("[callback](QVariant v) {"));
}