Files
logos-view-module-runtime/tests/test_logos_qml_bridge.cpp

332 lines
12 KiB
C++

// Unit tests for LogosQmlBridge.
//
// Scope:
// 1. serializeResultForTesting — pure JSON serialization logic. Covered
// exhaustively because it is the contract between C++ backend modules
// and the QML callers that parse the returned string with JSON.parse().
// 2. Routing state — view-module registration, crash cache invalidation,
// and the guard that rejects callModule() calls on registered view
// modules. These paths require no QRemoteObjectNode / socket.
// 3. Degraded behavior — a bridge constructed with a null LogosAPI*
// must return a well-formed error payload rather than crashing.
//
// Intentionally NOT covered here (those belong to integration tests in
// logos-test-modules):
// - Actually spawning ui-host and talking to a real view plugin.
// - QRemoteObjectNode acquisition / typed-replica lifecycle.
// - QPluginLoader loading of a real LogosViewReplicaFactory plugin.
#include "LogosQmlBridge.h"
#include <QCoreApplication>
#include <QJSEngine>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonValue>
#include <QSignalSpy>
#include <QTest>
#include <QVariant>
#include <QVariantList>
#include <QVariantMap>
namespace {
// Parse a JSON payload that the bridge produces, regardless of whether it is
// an object, array, primitive, or the literal word "null". Returns an
// invalid QJsonValue on parse failure so assertions stay meaningful.
QJsonValue parsePayload(const QString& payload)
{
const QByteArray bytes = payload.toUtf8();
QJsonParseError err{};
// Try object/array first.
const QJsonDocument doc = QJsonDocument::fromJson(bytes, &err);
if (err.error == QJsonParseError::NoError) {
if (doc.isObject()) return QJsonValue(doc.object());
if (doc.isArray()) return QJsonValue(doc.array());
}
// Primitive: wrap in an array so QJsonDocument can parse it.
const QByteArray wrapped = QByteArray("[") + bytes + "]";
const QJsonDocument wrappedDoc = QJsonDocument::fromJson(wrapped, &err);
if (err.error == QJsonParseError::NoError && wrappedDoc.isArray() &&
wrappedDoc.array().size() == 1) {
return wrappedDoc.array().at(0);
}
return QJsonValue::Undefined;
}
} // namespace
class TestLogosQmlBridge : public QObject {
Q_OBJECT
private slots:
// ── serializeResultForTesting ───────────────────────────────────────
void serializeResult_invalidVariant_returnsNullLiteral()
{
QCOMPARE(LogosQmlBridge::serializeResultForTesting(QVariant()),
QStringLiteral("null"));
}
void serializeResult_int()
{
const QString s = LogosQmlBridge::serializeResultForTesting(QVariant(42));
const QJsonValue v = parsePayload(s);
QVERIFY(v.isDouble());
QCOMPARE(v.toInt(), 42);
}
void serializeResult_bool()
{
QCOMPARE(parsePayload(LogosQmlBridge::serializeResultForTesting(QVariant(true))).toBool(), true);
QCOMPARE(parsePayload(LogosQmlBridge::serializeResultForTesting(QVariant(false))).toBool(), false);
}
void serializeResult_double()
{
const QJsonValue v = parsePayload(
LogosQmlBridge::serializeResultForTesting(QVariant(3.5)));
QVERIFY(v.isDouble());
QCOMPARE(v.toDouble(), 3.5);
}
void serializeResult_string_roundtripsThroughJson()
{
const QString s = LogosQmlBridge::serializeResultForTesting(
QVariant(QStringLiteral("hello \"world\"\n")));
const QJsonValue v = parsePayload(s);
QVERIFY(v.isString());
QCOMPARE(v.toString(), QStringLiteral("hello \"world\"\n"));
}
void serializeResult_stringList()
{
QStringList list{"a", "b", "c"};
const QJsonValue v = parsePayload(
LogosQmlBridge::serializeResultForTesting(QVariant(list)));
QVERIFY(v.isArray());
QCOMPARE(v.toArray().size(), 3);
QCOMPARE(v.toArray().at(1).toString(), QStringLiteral("b"));
}
void serializeResult_variantMap_producesObject()
{
QVariantMap m;
m["name"] = "foo";
m["count"] = 7;
m["enabled"] = true;
const QJsonValue v = parsePayload(
LogosQmlBridge::serializeResultForTesting(QVariant(m)));
QVERIFY(v.isObject());
const QJsonObject obj = v.toObject();
QCOMPARE(obj.value("name").toString(), QStringLiteral("foo"));
QCOMPARE(obj.value("count").toInt(), 7);
QCOMPARE(obj.value("enabled").toBool(), true);
}
void serializeResult_nestedVariantMap()
{
QVariantMap inner;
inner["x"] = 1;
inner["y"] = 2;
QVariantMap outer;
outer["point"] = inner;
outer["label"] = "origin";
const QJsonValue v = parsePayload(
LogosQmlBridge::serializeResultForTesting(QVariant(outer)));
QVERIFY(v.isObject());
const QJsonObject obj = v.toObject();
QVERIFY(obj.value("point").isObject());
QCOMPARE(obj.value("point").toObject().value("x").toInt(), 1);
QCOMPARE(obj.value("point").toObject().value("y").toInt(), 2);
QCOMPARE(obj.value("label").toString(), QStringLiteral("origin"));
}
void serializeResult_variantList()
{
QVariantList list{QVariant(1), QVariant("two"), QVariant(true)};
const QJsonValue v = parsePayload(
LogosQmlBridge::serializeResultForTesting(QVariant(list)));
QVERIFY(v.isArray());
QCOMPARE(v.toArray().size(), 3);
QCOMPARE(v.toArray().at(0).toInt(), 1);
QCOMPARE(v.toArray().at(1).toString(), QStringLiteral("two"));
QCOMPARE(v.toArray().at(2).toBool(), true);
}
void serializeResult_compactFormatting_noWhitespace()
{
QVariantMap m;
m["a"] = 1;
const QString s = LogosQmlBridge::serializeResultForTesting(QVariant(m));
// Compact JSON: no embedded spaces/newlines.
QVERIFY(!s.contains(QLatin1Char(' ')));
QVERIFY(!s.contains(QLatin1Char('\n')));
}
// ── Routing & state (no real LogosAPI) ───────────────────────────────
void nullLogosAPI_callModule_returnsErrorPayload()
{
LogosQmlBridge bridge(nullptr);
const QString s = bridge.callModule("any", "method", {});
const QJsonValue v = parsePayload(s);
QVERIFY(v.isObject());
QCOMPARE(v.toObject().value("error").toString(),
QStringLiteral("LogosAPI not available"));
}
void callModule_onRegisteredViewModule_refuses()
{
LogosQmlBridge bridge(nullptr);
bridge.setViewModuleSocket("my_view", "socket-abc");
const QString s = bridge.callModule("my_view", "ping", {});
const QJsonValue v = parsePayload(s);
QVERIFY(v.isObject());
QVERIFY(v.toObject().value("error").toString().contains(
QStringLiteral("view modules")));
}
void callModuleAsync_nullLogosAPI_invokesCallbackWithError()
{
// callModuleAsync takes a QJSValue callback. Use a QJSEngine to
// create a callable that stashes its argument on the global scope
// so we can read it back.
QJSEngine engine;
engine.globalObject().setProperty("captured", QJSValue());
QJSValue fn = engine.evaluate(
"(function(msg){ captured = msg; })");
QVERIFY(fn.isCallable());
LogosQmlBridge bridge(nullptr);
bridge.callModuleAsync("backend", "m", {}, fn, /*timeoutMs*/ 0);
const QString payload =
engine.globalObject().property("captured").toString();
const QJsonValue v = parsePayload(payload);
QVERIFY(v.isObject());
QCOMPARE(v.toObject().value("error").toString(),
QStringLiteral("LogosAPI not available"));
}
void callModuleAsync_onRegisteredViewModule_refuses()
{
QJSEngine engine;
engine.globalObject().setProperty("captured", QJSValue());
QJSValue fn = engine.evaluate(
"(function(msg){ captured = msg; })");
QVERIFY(fn.isCallable());
LogosQmlBridge bridge(nullptr);
bridge.setViewModuleSocket("v", "sock");
bridge.callModuleAsync("v", "m", {}, fn, /*timeoutMs*/ 0);
const QString payload =
engine.globalObject().property("captured").toString();
const QJsonValue v = parsePayload(payload);
QVERIFY(v.isObject());
QVERIFY(v.toObject().value("error").toString().contains(
QStringLiteral("view modules")));
}
// ── View-module registration state ──────────────────────────────────
void setViewModuleSocket_storesMapping()
{
LogosQmlBridge bridge(nullptr);
QVERIFY(!bridge.hasViewModuleSocket("foo"));
bridge.setViewModuleSocket("foo", "lgx-abc");
QVERIFY(bridge.hasViewModuleSocket("foo"));
QCOMPARE(bridge.viewModuleSocket("foo"), QStringLiteral("lgx-abc"));
}
void setViewModuleSocket_overwritingWithSameValue_isNoop()
{
LogosQmlBridge bridge(nullptr);
bridge.setViewModuleSocket("foo", "sock-1");
bridge.setViewModuleSocket("foo", "sock-1");
QCOMPARE(bridge.viewModuleSocket("foo"), QStringLiteral("sock-1"));
}
void setViewModuleSocket_differentSocket_replacesMapping()
{
LogosQmlBridge bridge(nullptr);
bridge.setViewModuleSocket("foo", "sock-1");
bridge.setViewModuleSocket("foo", "sock-2");
QCOMPARE(bridge.viewModuleSocket("foo"), QStringLiteral("sock-2"));
}
void setViewReplicaPlugin_storesPath()
{
LogosQmlBridge bridge(nullptr);
QVERIFY(bridge.viewReplicaPluginPath("foo").isEmpty());
bridge.setViewReplicaPlugin("foo", "/tmp/does-not-exist.so");
QCOMPARE(bridge.viewReplicaPluginPath("foo"),
QStringLiteral("/tmp/does-not-exist.so"));
}
void isViewModuleReady_unknownModule_false()
{
LogosQmlBridge bridge(nullptr);
QVERIFY(!bridge.isViewModuleReady("nope"));
}
// ── Crash notification ──────────────────────────────────────────────
void notifyViewModuleCrashed_emitsBothSignalsAndMarksNotReady()
{
LogosQmlBridge bridge(nullptr);
bridge.setViewModuleSocket("foo", "sock-1");
QSignalSpy readyChangedSpy(
&bridge, &LogosQmlBridge::viewModuleReadyChanged);
QSignalSpy crashedSpy(
&bridge, &LogosQmlBridge::viewModuleCrashed);
bridge.notifyViewModuleCrashed("foo");
QCOMPARE(crashedSpy.count(), 1);
QCOMPARE(crashedSpy.first().at(0).toString(), QStringLiteral("foo"));
QCOMPARE(readyChangedSpy.count(), 1);
QCOMPARE(readyChangedSpy.first().at(0).toString(), QStringLiteral("foo"));
QCOMPARE(readyChangedSpy.first().at(1).toBool(), false);
QVERIFY(!bridge.isViewModuleReady("foo"));
}
// ── Module event subscription ─────────────────────────────────────
void onModuleEvent_nullLogosAPI_returnsFalse()
{
LogosQmlBridge bridge(nullptr);
QVERIFY(!bridge.onModuleEvent("calc_module", "resultReady"));
}
void onModuleEvent_nullLogosAPI_doesNotEmitSignal()
{
LogosQmlBridge bridge(nullptr);
QSignalSpy spy(&bridge, &LogosQmlBridge::moduleEventReceived);
bridge.onModuleEvent("calc_module", "resultReady");
QCOMPARE(spy.count(), 0);
}
// ── Crash notification ──────────────────────────────────────────────
void notifyViewModuleCrashed_dropsSocketMapping()
{
LogosQmlBridge bridge(nullptr);
bridge.setViewModuleSocket("foo", "sock-1");
bridge.notifyViewModuleCrashed("foo");
// After a crash notification, dropViewModuleCaches() clears the
// replica/model caches; the socket mapping is preserved so a
// respawn with the same socket can reconnect. Assert the bridge
// still knows the socket so it can reacquire.
QVERIFY(bridge.hasViewModuleSocket("foo"));
}
};
QTEST_GUILESS_MAIN(TestLogosQmlBridge)
#include "test_logos_qml_bridge.moc"