diff --git a/tests/auto/conformance/README.md b/tests/auto/conformance/README.md index 0ccaa61..d135e75 100644 --- a/tests/auto/conformance/README.md +++ b/tests/auto/conformance/README.md @@ -11,9 +11,18 @@ adaptateurs, sur les mocks de la suite `simulation` — et vérifie, une fonctio | `testTheSessionMatchesTheContract` | la session entière (les deux sens) est conforme au contrat OpenRPC **embarqué** dans le moteur, règles sémantiques comprises (`tools/etm-powersync-schema-check --semantic`) | | `testEveryPlanIsAccepted` | à chaque cycle, le plan est **appliqué** : l'optimiseur commande, `GetPlan` sans `fallbackCode` | | `testTheSafetyLoopStaysOnTop` | la boucle de sécurité reste au-dessus : une consigne hors bornes (réécrite en 100 kW par le relais de test) est **écrêtée** par l'adaptateur (`clamped`), une surcharge de phase est **délestée** par L4 | +| `testGetPlanBeforeTheHandshakeIsRefused` | protocole 1.2, §3 : `GetPlan` avant un handshake réussi → erreur `-32600` à son `id`, connexion **gardée** (le handshake qui suit est accepté) | +| `testARefusedHandshakeIsClosedByTheOptimizer` | protocole 1.2, §3 : handshake d'une autre majeure → `-32602`, puis l'optimiseur **ferme** ; si un token est attendu, token faux → `-32001`, fermé pareil | +| `testABatchIsRefused` | protocole 1.2, §2 : un lot `[…]` → **une** erreur `-32600` à `id: null`, rien du lot traité, connexion gardée | +| `testALineOverTheLimitIsRefused` | protocole 1.2, §2 : une ligne de `maxLineBytes` + 1 octets → `-32600` à `id: null`, connexion gardée ; une ligne d'exactement `maxLineBytes` est lue | Ces deux dernières lignes valent pour le mode `plan` (défaut). Voir **Optimiseur qui s'abstient**. +Les fonctions 6 à 9 ne branchent pas le moteur : elles envoient, par une **connexion de sonde**, ce +que son client n'envoie jamais, et lisent la réponse brute (un lot renvoyé se verrait). Elles +utilisent des requêtes dont la réponse est normée sans contexte — `UpdateLearned` avec `id` → +`-32601` (§8) — pour constater qu'un message a été, ou n'a pas été, traité. + Les fonctions 2 à 5 passent par un relais transparent (une ligne reçue = une ligne renvoyée) qui capture la session et mesure les délais ; la 5 est la seule où il réécrit une réponse. Banc : deux charges `etmvariableload` (0–2 000 W ; plancher 800, 3 000 W) et une PAC SG-Ready (P3 1 500 W, @@ -30,7 +39,8 @@ cd /tests/auto/conformance export NYMEA_PLUGINS_PATH=/tests/mocks/plugins/energymocks:/usr/lib/x86_64-linux-gnu/nymea/plugins export ETM_POWERSYNC_OPTIMIZER_CMD='exec /chemin/de/mon-optimiseur --listen {endpoint}' for fn in testHandshakeIsAccepted testGetPlanIsAnsweredInTime testTheSessionMatchesTheContract \ - testEveryPlanIsAccepted testTheSafetyLoopStaysOnTop; do + testEveryPlanIsAccepted testTheSafetyLoopStaysOnTop testGetPlanBeforeTheHandshakeIsRefused \ + testARefusedHandshakeIsClosedByTheOptimizer testABatchIsRefused testALineOverTheLimitIsRefused; do systemd-run --user --scope -q -p MemoryMax=2G \ bwrap --dev-bind / / --unshare-net --tmpfs /tmp --bind "$PWD/../../.." "$PWD/../../.." \ --bind --chdir "$PWD" ./target_wrapper.sh ./etm-powersync-optimizer-conformance "$fn" @@ -65,7 +75,7 @@ toujours est conforme. Avec `ETM_POWERSYNC_CONFORMANCE_EXPECT=abstain` : - `testTheSafetyLoopStaysOnTop` vérifie toujours L4 sous le rule-based, sans consigne de l'optimiseur : la trace doit porter `shed` (la seule baisse des consignes ne suffit pas, l'import la produit déjà). Puis la fonction se termine en **SKIP** : aucune consigne à écrêter ; -- les trois autres fonctions sont inchangées. +- les autres fonctions sont inchangées. À l'inverse, un optimiseur qui s'abstient en mode `plan` échoue à `testEveryPlanIsAccepted` et `testTheSafetyLoopStaysOnTop`. Chaque fonction affiche en tête le mode retenu (`[Conformité] mode @@ -81,6 +91,9 @@ fondée sur ces champs jugerait faux dans les deux cas : le mode se donne, il ne ## Ce qu'elle ne vérifie pas La **qualité** des plans (aucune comparaison au rule-based : c'est `refoptimizer`, pour la référence -seulement), les bornes VE (aucune borne au banc), `UpdateLearned` (ignoré par le moteur 1.1), le -comportement sous défaut de transport. Le contrat : `docs/etm-powersync-optimizer-protocol.openrpc.json` +seulement), les bornes VE (aucune borne au banc), `UpdateLearned` émis par l'optimiseur (ignoré par le moteur ; sa +forme n'est pas normée en 1.2), le token envoyé à un optimiseur qui n'en exige pas (§3, 1.2 : accepté ; +le moteur n'envoie de token que si l'installateur en a posé un), la réponse à un contexte `GetPlan` +invalide (§7, 1.2 : `-32602` ; la forme d'`error.data` n'est pas normée), le comportement sous défaut +de transport. Le contrat : `docs/etm-powersync-optimizer-protocol.openrpc.json` (prose : `docs/OPTIMIZER_PROTOCOL.md`, annexe G). diff --git a/tests/auto/conformance/testconformance.cpp b/tests/auto/conformance/testconformance.cpp index 217f04f..5b0f09f 100644 --- a/tests/auto/conformance/testconformance.cpp +++ b/tests/auto/conformance/testconformance.cpp @@ -16,7 +16,11 @@ // sémantiques comprises — jugée par le validateur publié ; // 4. chaque plan ACCEPTÉ : l'optimiseur commande, `GetPlan` sans `fallbackCode` ; // 5. la boucle de sécurité au-dessus : une consigne hors bornes est écrêtée par l'adaptateur, -// une surcharge de phase est délestée par L4, quoi que dise l'optimiseur. +// une surcharge de phase est délestée par L4, quoi que dise l'optimiseur ; +// 6 à 9 (protocole 1.2). Ce que le moteur n'envoie JAMAIS, envoyé par une connexion de SONDE +// (sans le moteur) : GetPlan avant le handshake (-32600, connexion gardée), handshake d'une +// autre majeure (-32602 puis fermeture par l'optimiseur), lot (-32600 à id null, rien du lot +// traité), ligne de plus de 1 Mio (-32600 à id null, connexion gardée). // // Mode attendu ($ETM_POWERSYNC_CONFORMANCE_EXPECT, défaut `plan`) : `abstain` juge un optimiseur // qui s'abstient toujours (§7, réponse permise) — le point 4 exige alors une abstention à chaque @@ -52,6 +56,7 @@ #include #include +#include #include using namespace nymeaserver; @@ -319,6 +324,69 @@ private: QElapsedTimer m_chrono; }; +/*! + * Connexion de SONDE (protocole 1.2) : des lignes écrites à la main, les réponses lues une à une, + * sans le moteur — pour ce que son client n'envoie jamais. Une réponse est gardée BRUTE aussi : un + * lot renvoyé (`[…]`) n'est pas un objet, et QJsonDocument::object() le rendrait vide. + */ +class Sonde +{ +public: + explicit Sonde(const QString &endpoint) : m_s(socketVers(endpoint, &m_proprietaire)) {} + + bool ouverte() { return m_s && waitFor([this] { return connecte(m_s); }, 5000); } + + void envoyer(const QByteArray &ligne) + { + m_s->write(ligne + '\n'); + if (auto *l = qobject_cast(m_s)) + l->flush(); + else if (auto *t = qobject_cast(m_s)) + t->flush(); + } + + //! La ligne suivante (sans son '\n') ; faux si rien n'arrive en \p ms, ou si la connexion se ferme avant. + bool recevoir(QByteArray *ligne, int ms = 5000) + { + waitFor([this] { lire(); return m_tampon.contains('\n') || !connecte(m_s); }, ms); + lire(); + const int nl = m_tampon.indexOf('\n'); + if (nl < 0) + return false; + *ligne = m_tampon.left(nl); + m_tampon.remove(0, nl + 1); + return true; + } + + //! Vrai si l'AUTRE côté ferme en \p ms : la sonde ne ferme jamais d'elle-même. + bool fermeeParLautre(int ms) { return waitFor([this] { lire(); return !connecte(m_s); }, ms); } + +private: + void lire() { m_tampon += m_s->readAll(); } + + QObject m_proprietaire; + QIODevice *m_s; + QByteArray m_tampon; +}; + +//! Une réponse error de la sonde : `{"jsonrpc":"2.0","id":,"error":{"code":,…}}`, objet seul. +QString verifierErreur(const QByteArray &ligne, const QJsonValue &id, int code) +{ + QJsonParseError e; + const QJsonDocument doc = QJsonDocument::fromJson(ligne, &e); + const QString vu = QString::fromUtf8(ligne.left(300)); + if (e.error != QJsonParseError::NoError || !doc.isObject()) + return QStringLiteral("réponse qui n'est pas UN objet JSON : ") + vu; + const QJsonObject m = doc.object(); + if (!m.contains("error") || m.value("error").toObject().value("code").toInt() != code) + return QStringLiteral("erreur %1 attendue : ").arg(code) + vu; + const QJsonValue recu = m.value("id"); + const bool bonId = id.isNull() ? m.contains("id") && recu.isNull() : recu.isDouble() && recu.toDouble() == id.toDouble(); + if (!bonId) + return QStringLiteral("id %1 attendu : ").arg(id.isNull() ? QStringLiteral("null") : QString::number(id.toInt())) + vu; + return QString(); +} + QString cle(const QVariantMap &v) { return QString::fromUtf8(QJsonDocument::fromVariant(v).toJson(QJsonDocument::Compact)); @@ -336,9 +404,23 @@ private slots: void testTheSessionMatchesTheContract(); void testEveryPlanIsAccepted(); void testTheSafetyLoopStaysOnTop(); + void testGetPlanBeforeTheHandshakeIsRefused(); + void testARefusedHandshakeIsClosedByTheOptimizer(); + void testABatchIsRefused(); + void testALineOverTheLimitIsRefused(); private: void monter(); + //! Où l'optimiseur écoute ($ETM_POWERSYNC_OPTIMIZER_ENDPOINT, sinon un socket du test). + QString endpoint() const; + //! Le token que l'optimiseur attend ($ETM_POWERSYNC_OPTIMIZER_TOKEN_FILE), vide sinon. + QString lireToken(); + //! Points 6 à 9 : l'optimiseur seul, sans moteur ; \p sonde ouverte dessus. + void sonder(std::unique_ptr &sonde); + //! Un Handshake de sonde en \p version, avec \p token s'il n'est pas vide. + QByteArray handshake(int id, const QString &version, const QString &token) const; + //! Handshake accepté sur \p sonde, ou échec du test. + void ouvrirSession(Sonde &sonde, int id); //! Lance l'optimiseur et y branche le moteur (directement, ou par le relais). void brancher(bool parLeRelais); void cycle(const QDateTime &t); @@ -437,20 +519,32 @@ void TestOptimizerConformance::monter() m_arb->setOptimizerTraceDirectory(d); QCOMPARE(rpc("SetOptimizerTrace", {{"enabled", true}}).value("energyError").toString(), QString("EnergyErrorNoError")); // Token : celui que l'optimiseur attend, s'il en attend un. - const QString fichier = variable("ETM_POWERSYNC_OPTIMIZER_TOKEN_FILE"); - if (!fichier.isEmpty()) { - QFile f(fichier); - QVERIFY2(f.open(QIODevice::ReadOnly), qUtf8Printable("ETM_POWERSYNC_OPTIMIZER_TOKEN_FILE illisible : " + fichier)); - m_token = QString::fromUtf8(f.readAll().trimmed()); + m_token = lireToken(); + if (QTest::currentTestFailed()) + return; + if (!m_token.isEmpty()) QCOMPARE(rpc("SetOptimizerToken", {{"token", m_token}}).value("energyError").toString(), QString("EnergyErrorNoError")); - } m_surplusW = 0; } +QString TestOptimizerConformance::endpoint() const +{ + return variable("ETM_POWERSYNC_OPTIMIZER_ENDPOINT", QStringLiteral("unix://%1/optimiseur.sock").arg(m_dir.path())); +} + +QString TestOptimizerConformance::lireToken() +{ + const QString fichier = variable("ETM_POWERSYNC_OPTIMIZER_TOKEN_FILE"); + if (fichier.isEmpty()) + return QString(); + QFile f(fichier); + [&] { QVERIFY2(f.open(QIODevice::ReadOnly), qUtf8Printable("ETM_POWERSYNC_OPTIMIZER_TOKEN_FILE illisible : " + fichier)); }(); + return QString::fromUtf8(f.readAll().trimmed()); +} + void TestOptimizerConformance::brancher(bool parLeRelais) { - const QString endpoint = variable("ETM_POWERSYNC_OPTIMIZER_ENDPOINT", - QStringLiteral("unix://%1/optimiseur.sock").arg(m_dir.path())); + const QString endpoint = this->endpoint(); QString erreur; QVERIFY2(m_optimiseur.demarrer(endpoint, &erreur), qUtf8Printable(erreur)); QString cible = endpoint; @@ -710,5 +804,136 @@ void TestOptimizerConformance::testTheSafetyLoopStaysOnTop() } } +// --- 6 à 9 : protocole 1.2, par une connexion de sonde ---------------------------------------------- + +void TestOptimizerConformance::sonder(std::unique_ptr &sonde) +{ + QVERIFY(m_dir.isValid()); + m_token = lireToken(); + if (QTest::currentTestFailed()) + return; + QString erreur; + QVERIFY2(m_optimiseur.demarrer(endpoint(), &erreur), qUtf8Printable(erreur)); + sonde.reset(new Sonde(endpoint())); + QVERIFY2(sonde->ouverte(), qUtf8Printable("la sonde ne se connecte pas à " + endpoint())); +} + +QByteArray TestOptimizerConformance::handshake(int id, const QString &version, const QString &token) const +{ + QJsonObject params{{"protocolVersion", version}, {"engine", "etm-powersync-energy-plugin-etm/conformance"}}; + if (!token.isEmpty()) + params.insert("token", token); + return QJsonDocument(QJsonObject{{"jsonrpc", "2.0"}, {"id", id}, {"method", "Handshake"}, {"params", params}}) + .toJson(QJsonDocument::Compact); +} + +void TestOptimizerConformance::ouvrirSession(Sonde &sonde, int id) +{ + sonde.envoyer(handshake(id, QString::fromLatin1(OptimizerProtocol::ProtocolVersion), m_token)); + QByteArray ligne; + QVERIFY2(sonde.recevoir(&ligne), "handshake de sonde sans réponse"); + const QJsonObject m = QJsonDocument::fromJson(ligne).object(); + QVERIFY2(m.value("id").toInt() == id && m.value("result").toObject().value("protocolVersion").isString(), + qUtf8Printable("handshake de sonde refusé : " + QString::fromUtf8(ligne.left(300)))); +} + +void TestOptimizerConformance::testGetPlanBeforeTheHandshakeIsRefused() +{ + // §3 (1.2) : GetPlan avant un handshake réussi → -32600, et la connexion est GARDÉE — le + // handshake qui suit, sur la même connexion, est accepté. + std::unique_ptr sonde; + sonder(sonde); + if (QTest::currentTestFailed()) + return; + sonde->envoyer(R"({"jsonrpc":"2.0","id":1,"method":"GetPlan","params":{}})"); + QByteArray ligne; + QVERIFY2(sonde->recevoir(&ligne), "GetPlan avant le handshake : aucune réponse (ou connexion fermée)"); + const QString e = verifierErreur(ligne, 1, -32600); + QVERIFY2(e.isEmpty(), qUtf8Printable(e)); + ouvrirSession(*sonde, 2); +} + +void TestOptimizerConformance::testARefusedHandshakeIsClosedByTheOptimizer() +{ + // §3 (1.2) : un handshake refusé est fermé des deux côtés. Ici, la moitié de l'OPTIMISEUR : il + // ferme après avoir envoyé l'erreur (la sonde, elle, ne ferme pas). Majeure incompatible → + // -32602 ; si l'optimiseur attend un token, un token faux → -32001, fermé pareil. La moitié du + // moteur est éprouvée par la suite `optimizerclient` (testARefusedHandshakeIsClosedByTheEngine). + std::unique_ptr sonde; + sonder(sonde); + if (QTest::currentTestFailed()) + return; + const QString autreMajeure = QString::number(QString::fromLatin1(OptimizerProtocol::ProtocolVersion).section('.', 0, 0).toInt() + 1) + ".0"; + sonde->envoyer(handshake(1, autreMajeure, m_token)); + QByteArray ligne; + QVERIFY2(sonde->recevoir(&ligne), qUtf8Printable("handshake en " + autreMajeure + " : aucune réponse avant la fermeture")); + QString e = verifierErreur(ligne, 1, -32602); + QVERIFY2(e.isEmpty(), qUtf8Printable(e)); + QVERIFY2(sonde->fermeeParLautre(5000), "handshake refusé (-32602) : l'optimiseur n'a pas fermé la connexion en 5 s"); + + if (m_token.isEmpty()) { + qInfo().noquote() << "[Conformité] aucun token attendu : refus -32001 non éprouvé"; + return; + } + Sonde faux(endpoint()); + QVERIFY(faux.ouverte()); + faux.envoyer(handshake(1, QString::fromLatin1(OptimizerProtocol::ProtocolVersion), m_token + QStringLiteral("-faux"))); + QVERIFY2(faux.recevoir(&ligne), "token faux : aucune réponse avant la fermeture"); + e = verifierErreur(ligne, 1, -32001); + QVERIFY2(e.isEmpty(), qUtf8Printable(e)); + QVERIFY2(faux.fermeeParLautre(5000), "handshake refusé (-32001) : l'optimiseur n'a pas fermé la connexion en 5 s"); +} + +void TestOptimizerConformance::testABatchIsRefused() +{ + // §2 (1.2) : lots interdits. Un lot reçu → UNE réponse -32600 à id null, aucun de ses messages + // traité, connexion gardée. Le lot porte une requête dont la réponse est normée (UpdateLearned + // avec id → -32601, §8) : la traiter se verrait. + std::unique_ptr sonde; + sonder(sonde); + if (QTest::currentTestFailed()) + return; + ouvrirSession(*sonde, 1); + if (QTest::currentTestFailed()) + return; + sonde->envoyer(R"([{"jsonrpc":"2.0","id":2,"method":"UpdateLearned","params":{}}])"); + QByteArray ligne; + QVERIFY2(sonde->recevoir(&ligne), "lot : aucune réponse (ou connexion fermée)"); + QString e = verifierErreur(ligne, QJsonValue::Null, -32600); + QVERIFY2(e.isEmpty(), qUtf8Printable(e)); + // Connexion gardée, et la ligne suivante répond à la requête suivante — pas au contenu du lot. + sonde->envoyer(R"({"jsonrpc":"2.0","id":3,"method":"UpdateLearned","params":{}})"); + QVERIFY2(sonde->recevoir(&ligne), "après le lot : aucune réponse (ou connexion fermée)"); + e = verifierErreur(ligne, 3, -32601); + QVERIFY2(e.isEmpty(), qUtf8Printable(e)); +} + +void TestOptimizerConformance::testALineOverTheLimitIsRefused() +{ + // §2 (1.2) : maxLineBytes dans les deux sens, '\n' non compté. Un octet de trop → -32600 à id + // null, la ligne n'est pas traitée, connexion gardée ; une ligne d'exactement maxLineBytes est + // lue normalement. + std::unique_ptr sonde; + sonder(sonde); + if (QTest::currentTestFailed()) + return; + ouvrirSession(*sonde, 1); + if (QTest::currentTestFailed()) + return; + auto ligneDe = [](int id, int taille) { + QByteArray l = QStringLiteral(R"({"jsonrpc":"2.0","id":%1,"method":"UpdateLearned","params":{}})").arg(id).toUtf8(); + return l + QByteArray(taille - l.size(), ' '); + }; + sonde->envoyer(ligneDe(2, OptimizerClient::kMaxLineBytes + 1)); + QByteArray ligne; + QVERIFY2(sonde->recevoir(&ligne, 10000), "ligne trop longue : aucune réponse (ou connexion fermée)"); + QString e = verifierErreur(ligne, QJsonValue::Null, -32600); + QVERIFY2(e.isEmpty(), qUtf8Printable(e)); + sonde->envoyer(ligneDe(3, OptimizerClient::kMaxLineBytes)); + QVERIFY2(sonde->recevoir(&ligne, 10000), "ligne d'exactement maxLineBytes : aucune réponse (ou connexion fermée)"); + e = verifierErreur(ligne, 3, -32601); + QVERIFY2(e.isEmpty(), qUtf8Printable(e)); +} + QTEST_MAIN(TestOptimizerConformance) #include "testconformance.moc"