mirror of
https://github.com/logos-co/logos-cpp-sdk.git
synced 2026-08-31 01:31:10 +00:00
Address review feedback: the first pass cleared pendingDoc on every specifier match, so a `///` comment above a collapsed `logos_events : void foo();` did not attach to the event. In the collapsed form there is nowhere else to put the doc comment, so this left documentation formatting-dependent — the same bug class as #76, one level up. Only clear pendingDoc for a *bare* specifier (a section boundary, matching Qt `signals:` semantics); when a declaration shares the line, keep the pending doc so the declaration parser attaches it. Extend the fixture with a `///`-documented same-line event and assert the description is captured. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
423 lines
16 KiB
C++
423 lines
16 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.");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Issue #76: a section specifier and a declaration on the *same* physical
|
|
// line (`logos_events : void foo();`, as clang-format / prettier produce)
|
|
// must be parsed identically to the newline-separated form. The code after
|
|
// the colon must not be discarded.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
TEST_F(ImplHeaderParserTest, SameLineSectionSpecifiers)
|
|
{
|
|
auto r = parseImplHeader(
|
|
fixturesDir() + "/same_line_events_impl.h",
|
|
"SameLineEventsImpl",
|
|
fixturesDir() + "/same_line_events_metadata.json",
|
|
err);
|
|
ASSERT_FALSE(r.hasError()) << r.error.toStdString();
|
|
|
|
auto findEvent = [&](const QString& name) -> const EventDecl* {
|
|
for (const auto& e : r.module.events)
|
|
if (e.name == name) return &e;
|
|
return nullptr;
|
|
};
|
|
auto findMethod = [&](const QString& name) -> const MethodDecl* {
|
|
for (const auto& m : r.module.methods)
|
|
if (m.name == name) return &m;
|
|
return nullptr;
|
|
};
|
|
|
|
// The exact prettier form from the issue:
|
|
// logos_events : void versionReady(const std::string &version);
|
|
// Previously the prototype after the colon was discarded entirely.
|
|
const EventDecl* versionReady = findEvent("versionReady");
|
|
ASSERT_NE(versionReady, nullptr)
|
|
<< "Same-line `logos_events :` prototype must still be parsed";
|
|
ASSERT_EQ(versionReady->params.size(), 1);
|
|
EXPECT_EQ(versionReady->params[0].name, "version");
|
|
EXPECT_EQ(versionReady->params[0].type.name, "tstr");
|
|
// The `///` doc comment above the collapsed line must attach: in the
|
|
// same-line form there is nowhere else for it to go, so documentation
|
|
// must not be formatting-dependent either.
|
|
EXPECT_EQ(versionReady->description, "Fired once the latest version is known.");
|
|
|
|
// An event declared after the section is already open, also same-line.
|
|
const EventDecl* downloadProgress = findEvent("downloadProgress");
|
|
ASSERT_NE(downloadProgress, nullptr);
|
|
ASSERT_EQ(downloadProgress->params.size(), 2);
|
|
EXPECT_EQ(downloadProgress->params[0].name, "id");
|
|
EXPECT_EQ(downloadProgress->params[0].type.name, "tstr");
|
|
EXPECT_EQ(downloadProgress->params[1].name, "percent");
|
|
EXPECT_EQ(downloadProgress->params[1].type.name, "int");
|
|
|
|
// The newline-separated form keeps working alongside the collapsed form.
|
|
EXPECT_NE(findEvent("shutdown"), nullptr);
|
|
|
|
// Exactly the three events above — no phantom or dropped entries.
|
|
EXPECT_EQ(r.module.events.size(), 3);
|
|
|
|
// The symmetric case: `public : <decl>` on one line must surface the
|
|
// method too (the access specifier no longer swallows the declaration).
|
|
const MethodDecl* greet = findMethod("greet");
|
|
ASSERT_NE(greet, nullptr)
|
|
<< "Same-line `public:` declaration must still be parsed";
|
|
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");
|
|
|
|
// The same-line events must land in events[], never leak into methods[].
|
|
EXPECT_EQ(findMethod("versionReady"), nullptr);
|
|
EXPECT_EQ(findMethod("downloadProgress"), nullptr);
|
|
}
|