Files
logos-cpp-sdk/tests/experimental/test_impl_header_parser.cpp
Dario LipicarandClaude Opus 4.8 7b62ac2017 Per-event documentation + getPluginEvents introspection (#71)
* Add per-event documentation + getPluginEvents introspection

Mirror the per-method documentation pipeline for events. Events
(declared in a universal module's logos_events: section) now carry a
description parsed from their /// doc comments, and are introspectable
at runtime via a new getPluginEvents framework call.

- lidl_ast: EventDecl gains a description field.
- impl_header_parser: capture the event's doc comment (previously
  discarded) and an optional metadata.json events[].description.
- lidl_gen_provider: generated universal provider emits
  getEvents() override, mirroring getMethods() (name/signature/
  parameters/description; no returnType/isInvokable — events are void).
- logos_provider_object: default-empty virtual getEvents() so the
  legacy provider path and QtProviderObject inherit empty.
- module_proxy / qt_provider_object: intercept getPluginEvents next to
  the getPluginMethods special-case.
- docs: spec + README event-documentation notes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Add unit tests for event documentation + getEvents generation

Address review feedback (#71): cover the event-introspection paths that
previously only had method-side tests.

- impl_header_parser test: assert metadata.json events[].description is
  parsed; new documented_events fixture asserts `///` doc-comment
  capture on a logos_events: block (multi-line joined with \n,
  adjacent-only, plain // ignored).
- lidl_gen_provider test: assert the generated dispatch contains
  getEvents() emitting each event's name/signature/parameters and an
  escaped description, and that events carry no returnType/isInvokable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Fold event introspection into getMethods() to keep the provider ABI stable

The previous approach added a getEvents() virtual to LogosProviderObject,
which inserted a new vtable slot and shifted every later slot — an ABI
break that would misdispatch virtual calls whenever an old and new
host/module were mixed across the in-process plugin boundary.

Instead, report events INSIDE the existing getMethods() call: it now
returns the module's whole interface, with each entry tagged
type "method" or "event" (events omit returnType/isInvokable). The
provider vtable is therefore byte-for-byte unchanged, so old/new hosts
and modules stay binary-compatible — a new host reading an old module
sees no event entries (zero events), and an old host reading a new
module just ignores the "type" field (cosmetic). An entry with no
"type" is treated as a method.

- logos_provider_object.h: remove the getEvents() virtual; document that
  getMethods() carries both, and why.
- generator (lidl_gen_provider): emit events as type "event" entries
  inside getMethods(); tag methods type "method"; no getEvents() output.
- module_proxy / qt_provider_object: getPluginMethods()/getPluginEvents()
  are now type-filtered views of getMethods(), plus a new
  getPluginInterface() returning the whole list. (These are name-
  dispatched Q_INVOKABLEs, not vtable surface — adding them is safe.)
- tests: generator asserts events fold into getMethods() tagged "event";
  ModuleProxy asserts the three filtered views; parser tests unchanged.
- docs: spec/project/docs/README updated, incl. an ABI rationale note.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 17:06:12 -03:00

352 lines
12 KiB
C++

#include <gtest/gtest.h>
#include "impl_header_parser.h"
#include <QCoreApplication>
#include <QDir>
#include <QFile>
#include <QTextStream>
// Helper: find the fixtures directory.
// 1. FIXTURES_DIR env var — set by CI to point to installed fixtures
// 2. FIXTURES_DIR compile define — set by CMake, works during ctest in nix sandbox
// 3. ../fixtures relative to binary — nix install layout ($out/bin/ + $out/fixtures/)
static QString fixturesDir()
{
// Environment variable takes priority (set by CI or user)
QByteArray envDir = qgetenv("FIXTURES_DIR");
if (!envDir.isEmpty() && QDir(envDir).exists())
return QString::fromUtf8(envDir);
#ifdef FIXTURES_DIR
if (QDir(FIXTURES_DIR).exists())
return QString(FIXTURES_DIR);
#endif
// Installed layout: $out/bin/experimental_tests + $out/fixtures/
QString binDir = QCoreApplication::applicationDirPath();
if (!binDir.isEmpty()) {
QString installed = QDir::cleanPath(binDir + "/../fixtures");
if (QDir(installed).exists())
return installed;
}
return QDir::currentPath() + "/fixtures";
}
class ImplHeaderParserTest : public ::testing::Test {
protected:
QString errOutput;
QTextStream err{&errOutput};
};
// ---------------------------------------------------------------------------
// Basic parsing
// ---------------------------------------------------------------------------
TEST_F(ImplHeaderParserTest, ParsesSampleImpl)
{
auto r = parseImplHeader(
fixturesDir() + "/sample_impl.h",
"SampleModuleImpl",
fixturesDir() + "/sample_metadata.json",
err);
ASSERT_FALSE(r.hasError()) << r.error.toStdString();
// Metadata from JSON
EXPECT_EQ(r.module.name, "sample_module");
EXPECT_EQ(r.module.version, "1.2.3");
EXPECT_EQ(r.module.description, "A sample module for testing");
EXPECT_EQ(r.module.category, "testing");
ASSERT_EQ(r.module.depends.size(), 2);
EXPECT_EQ(r.module.depends[0], "dep_a");
// Methods — should find all public methods, skip ctor/dtor/private
EXPECT_GE(r.module.methods.size(), 10);
}
TEST_F(ImplHeaderParserTest, MethodTypes)
{
auto r = parseImplHeader(
fixturesDir() + "/sample_impl.h",
"SampleModuleImpl",
fixturesDir() + "/sample_metadata.json",
err);
ASSERT_FALSE(r.hasError()) << r.error.toStdString();
// Find specific methods and check their types
auto findMethod = [&](const QString& name) -> const MethodDecl* {
for (const auto& m : r.module.methods)
if (m.name == name) return &m;
return nullptr;
};
// std::string greet(const std::string& name) → tstr
auto greet = findMethod("greet");
ASSERT_NE(greet, nullptr);
EXPECT_EQ(greet->returnType.name, "tstr");
ASSERT_EQ(greet->params.size(), 1);
EXPECT_EQ(greet->params[0].name, "name");
EXPECT_EQ(greet->params[0].type.name, "tstr");
// bool isValid(const std::string& input) → bool
auto isValid = findMethod("isValid");
ASSERT_NE(isValid, nullptr);
EXPECT_EQ(isValid->returnType.name, "bool");
// int64_t getCount() → int
auto getCount = findMethod("getCount");
ASSERT_NE(getCount, nullptr);
EXPECT_EQ(getCount->returnType.name, "int");
EXPECT_TRUE(getCount->params.isEmpty());
// uint64_t getSize() → uint
auto getSize = findMethod("getSize");
ASSERT_NE(getSize, nullptr);
EXPECT_EQ(getSize->returnType.name, "uint");
// double getScore() → float64
auto getScore = findMethod("getScore");
ASSERT_NE(getScore, nullptr);
EXPECT_EQ(getScore->returnType.name, "float64");
// void doNothing() → void
auto doNothing = findMethod("doNothing");
ASSERT_NE(doNothing, nullptr);
EXPECT_EQ(doNothing->returnType.name, "void");
// std::vector<std::string> getNames() → [tstr]
auto getNames = findMethod("getNames");
ASSERT_NE(getNames, nullptr);
EXPECT_EQ(getNames->returnType.kind, TypeExpr::Array);
EXPECT_EQ(getNames->returnType.elements[0].name, "tstr");
// std::vector<uint8_t> getData() → bstr
auto getData = findMethod("getData");
ASSERT_NE(getData, nullptr);
EXPECT_EQ(getData->returnType.name, "bstr");
// std::vector<int64_t> getIds() → [int]
auto getIds = findMethod("getIds");
ASSERT_NE(getIds, nullptr);
EXPECT_EQ(getIds->returnType.kind, TypeExpr::Array);
EXPECT_EQ(getIds->returnType.elements[0].name, "int");
// std::string combine(const std::string& a, const std::string& b, int64_t count)
auto combine = findMethod("combine");
ASSERT_NE(combine, nullptr);
EXPECT_EQ(combine->returnType.name, "tstr");
ASSERT_EQ(combine->params.size(), 3);
EXPECT_EQ(combine->params[0].name, "a");
EXPECT_EQ(combine->params[1].name, "b");
EXPECT_EQ(combine->params[2].name, "count");
EXPECT_EQ(combine->params[2].type.name, "int");
}
TEST_F(ImplHeaderParserTest, SkipsPrivateMethods)
{
auto r = parseImplHeader(
fixturesDir() + "/sample_impl.h",
"SampleModuleImpl",
fixturesDir() + "/sample_metadata.json",
err);
ASSERT_FALSE(r.hasError()) << r.error.toStdString();
for (const auto& m : r.module.methods) {
EXPECT_NE(m.name, "internalHelper") << "Private method should not be parsed";
}
}
// ---------------------------------------------------------------------------
// Empty class
// ---------------------------------------------------------------------------
TEST_F(ImplHeaderParserTest, EmptyClass)
{
auto r = parseImplHeader(
fixturesDir() + "/empty_class_impl.h",
"EmptyClassImpl",
fixturesDir() + "/empty_metadata.json",
err);
ASSERT_FALSE(r.hasError()) << r.error.toStdString();
EXPECT_TRUE(r.module.methods.isEmpty());
// Should have a warning in err output
EXPECT_TRUE(errOutput.contains("Warning"));
}
// ---------------------------------------------------------------------------
// Complex class with access specifier changes
// ---------------------------------------------------------------------------
TEST_F(ImplHeaderParserTest, ComplexAccessSpecifiers)
{
auto r = parseImplHeader(
fixturesDir() + "/complex_impl.h",
"ComplexModuleImpl",
fixturesDir() + "/empty_metadata.json",
err);
ASSERT_FALSE(r.hasError()) << r.error.toStdString();
auto findMethod = [&](const QString& name) -> const MethodDecl* {
for (const auto& m : r.module.methods)
if (m.name == name) return &m;
return nullptr;
};
// First public section
EXPECT_NE(findMethod("firstMethod"), nullptr);
// Second public section (after protected)
EXPECT_NE(findMethod("secondMethod"), nullptr);
EXPECT_NE(findMethod("thirdMethod"), nullptr);
// Protected method should be skipped
EXPECT_EQ(findMethod("protectedHelper"), nullptr);
// Private method should be skipped
EXPECT_EQ(findMethod("privateHelper"), nullptr);
}
// ---------------------------------------------------------------------------
// Error cases
// ---------------------------------------------------------------------------
TEST_F(ImplHeaderParserTest, MissingHeaderFile)
{
auto r = parseImplHeader(
"/nonexistent/path.h",
"Foo",
fixturesDir() + "/sample_metadata.json",
err);
EXPECT_TRUE(r.hasError());
EXPECT_TRUE(r.error.contains("Failed to open header"));
}
TEST_F(ImplHeaderParserTest, MissingMetadataFile)
{
auto r = parseImplHeader(
fixturesDir() + "/sample_impl.h",
"SampleModuleImpl",
"/nonexistent/metadata.json",
err);
EXPECT_TRUE(r.hasError());
EXPECT_TRUE(r.error.contains("Failed to open metadata"));
}
TEST_F(ImplHeaderParserTest, WrongClassName)
{
auto r = parseImplHeader(
fixturesDir() + "/sample_impl.h",
"NonExistentClass",
fixturesDir() + "/sample_metadata.json",
err);
// Not an error per se, but should find zero methods and warn
ASSERT_FALSE(r.hasError());
EXPECT_TRUE(r.module.methods.isEmpty());
EXPECT_TRUE(errOutput.contains("Warning"));
}
// ---------------------------------------------------------------------------
// LogosMap / LogosList, Qt collections, metadata events, emitEvent detection
// ---------------------------------------------------------------------------
TEST_F(ImplHeaderParserTest, UniversalTypesAndMetadataEvents)
{
auto r = parseImplHeader(
fixturesDir() + "/universal_impl.h",
"UniversalImpl",
fixturesDir() + "/universal_metadata.json",
err);
ASSERT_FALSE(r.hasError()) << r.error.toStdString();
EXPECT_EQ(r.module.name, "universal_mod");
EXPECT_EQ(r.module.version, "2.0.0");
ASSERT_EQ(r.module.events.size(), 1);
EXPECT_EQ(r.module.events[0].name, "onReady");
// Optional per-event description carried from metadata.json events[].
EXPECT_EQ(r.module.events[0].description, "Fired once the module is ready.");
ASSERT_EQ(r.module.events[0].params.size(), 1);
EXPECT_EQ(r.module.events[0].params[0].name, "info");
EXPECT_EQ(r.module.events[0].params[0].type.name, "tstr");
auto findMethod = [&](const QString& name) -> const MethodDecl* {
for (const auto& m : r.module.methods)
if (m.name == name) return &m;
return nullptr;
};
// The fixture declares a `std::function<…> emitEvent` member. The old
// legacy hook treated it specially; now such members are simply skipped
// and never mistaken for a callable method.
EXPECT_EQ(findMethod("emitEvent"), nullptr);
auto fetchMap = findMethod("fetchMap");
ASSERT_NE(fetchMap, nullptr);
EXPECT_EQ(fetchMap->returnType.kind, TypeExpr::Map);
EXPECT_TRUE(fetchMap->jsonReturn);
auto fetchList = findMethod("fetchList");
ASSERT_NE(fetchList, nullptr);
EXPECT_EQ(fetchList->returnType.kind, TypeExpr::Array);
EXPECT_EQ(fetchList->returnType.elements[0].name, "any");
EXPECT_TRUE(fetchList->jsonReturn);
auto asVariantMap = findMethod("asVariantMap");
ASSERT_NE(asVariantMap, nullptr);
EXPECT_EQ(asVariantMap->returnType.kind, TypeExpr::Map);
EXPECT_FALSE(asVariantMap->jsonReturn);
auto listNames = findMethod("listNames");
ASSERT_NE(listNames, nullptr);
EXPECT_EQ(listNames->returnType.kind, TypeExpr::Array);
EXPECT_EQ(listNames->returnType.elements[0].name, "tstr");
EXPECT_FALSE(listNames->jsonReturn);
auto anyList = findMethod("anyList");
ASSERT_NE(anyList, nullptr);
EXPECT_EQ(anyList->returnType.kind, TypeExpr::Array);
EXPECT_EQ(anyList->returnType.elements[0].name, "any");
EXPECT_FALSE(anyList->jsonReturn);
auto fetchResult = findMethod("fetchResult");
ASSERT_NE(fetchResult, nullptr);
EXPECT_EQ(fetchResult->returnType.kind, TypeExpr::Primitive);
EXPECT_EQ(fetchResult->returnType.name, "result");
EXPECT_FALSE(fetchResult->jsonReturn);
EXPECT_TRUE(fetchResult->resultReturn);
for (const auto& m : r.module.methods) {
EXPECT_NE(m.name, "void") << "Keyword should not appear as method name";
}
}
// ---------------------------------------------------------------------------
// Event doc comments: `///` above a `logos_events:` declaration becomes the
// event's description (same capture rules as methods: doc-comments only,
// adjacent-only, multi-line joined with \n).
// ---------------------------------------------------------------------------
TEST_F(ImplHeaderParserTest, EventDocCommentsFromHeader)
{
auto r = parseImplHeader(
fixturesDir() + "/documented_events_impl.h",
"DocumentedEventsImpl",
fixturesDir() + "/documented_events_metadata.json",
err);
ASSERT_FALSE(r.hasError()) << r.error.toStdString();
ASSERT_EQ(r.module.events.size(), 3);
// Multi-line `///` doc comment: the two lines are joined with a newline.
EXPECT_EQ(r.module.events[0].name, "userLoggedIn");
EXPECT_EQ(r.module.events[0].description,
"Fired once the user has authenticated.\n"
"Carries the freshly issued session token.");
ASSERT_EQ(r.module.events[0].params.size(), 2);
EXPECT_EQ(r.module.events[0].params[0].name, "userId");
EXPECT_EQ(r.module.events[0].params[1].name, "token");
// A plain `//` comment is not a doc comment → no description captured.
EXPECT_EQ(r.module.events[1].name, "heartbeat");
EXPECT_TRUE(r.module.events[1].description.isEmpty());
// Single-line `///` doc comment.
EXPECT_EQ(r.module.events[2].name, "shutdown");
EXPECT_EQ(r.module.events[2].description, "Single-line documented event.");
}