diff --git a/tests/auto/common/etm-powersync-fakeoptimizer.h b/tests/auto/common/etm-powersync-fakeoptimizer.h index 77f4448..ead5501 100644 --- a/tests/auto/common/etm-powersync-fakeoptimizer.h +++ b/tests/auto/common/etm-powersync-fakeoptimizer.h @@ -130,6 +130,13 @@ public: int connections = 0; int disconnections = 0; + //! Tout ce que le MOTEUR a envoyé à un faux optimiseur dans ce processus, dans l'ordre — jugé + //! contre le contrat OpenRPC par la suite (lot « contrat OpenRPC »), puis vidé. + static QList &capture() + { + static QList messages; + return messages; + } //! Répondeur automatique (brief 4 T3) : appelé sur chaque message reçu ; s'il rend vrai, le //! message est consommé et n'entre pas dans la file de waitMessage(). Vide = tout est filé. std::function responder; @@ -146,6 +153,7 @@ private: while ((nl = m_buffer.indexOf('\n')) >= 0) { const QJsonObject message = QJsonDocument::fromJson(m_buffer.left(nl)).object(); m_buffer.remove(0, nl + 1); + capture().append(message); if (!(responder && responder(message))) m_messages.append(message); } diff --git a/tests/auto/optimizerclient/optimizerclient.pro b/tests/auto/optimizerclient/optimizerclient.pro index 71c5a05..befaee1 100644 --- a/tests/auto/optimizerclient/optimizerclient.pro +++ b/tests/auto/optimizerclient/optimizerclient.pro @@ -6,6 +6,10 @@ PKGCONFIG += nymea TARGET = etm-powersync-optimizerclient +# Lot contrat OpenRPC : les Handshake envoyés sont jugés par tools/etm-powersync-schema-check contre +# docs/etm-powersync-optimizer-protocol.openrpc.json. Surchargée par $ETM_POWERSYNC_SOURCE_DIR. +DEFINES += ETM_POWERSYNC_SOURCE_DIR=\\\"$$clean_path($$PWD/../../..)\\\" + # Cible VOLONTAIREMENT légère, comme loadmodel : aucun serveur nymea, aucun EnergyTestBase, et # du moteur le seul client de transport (brief 4 T2). Les faux optimiseurs sont des # QLocalServer/QTcpServer du processus de test, et le temps est SIMULÉ : aucun des délais @@ -15,7 +19,8 @@ INCLUDEPATH += $$PWD/../../../energyplugin HEADERS += $$PWD/../../../energyplugin/etm/scheduler/optimizerclient.h \ $$PWD/../../../energyplugin/etm/scheduler/optimizersecret.h \ - $$PWD/../common/etm-powersync-fakeoptimizer.h + $$PWD/../common/etm-powersync-fakeoptimizer.h \ + $$PWD/../common/etm-powersync-contrat.h SOURCES += $$PWD/../../../energyplugin/etm/scheduler/optimizerclient.cpp \ $$PWD/../../../energyplugin/etm/scheduler/optimizersecret.cpp \ testoptimizerclient.cpp diff --git a/tests/auto/optimizerclient/testoptimizerclient.cpp b/tests/auto/optimizerclient/testoptimizerclient.cpp index 2db6918..82ed520 100644 --- a/tests/auto/optimizerclient/testoptimizerclient.cpp +++ b/tests/auto/optimizerclient/testoptimizerclient.cpp @@ -7,6 +7,7 @@ #include "etm/scheduler/optimizerclient.h" #include "../common/etm-powersync-fakeoptimizer.h" +#include "../common/etm-powersync-contrat.h" #include #include @@ -88,6 +89,7 @@ class TestOptimizerClient : public QObject private slots: void initTestCase(); + void cleanupTestCase(); void init(); void testEndpointParsing_data(); @@ -118,6 +120,21 @@ private: QTemporaryDir m_dir; }; +void TestOptimizerClient::cleanupTestCase() +{ + // Lot contrat OpenRPC : chaque Handshake que le client a ENVOYÉ dans ce processus est jugé contre + // le contrat (docs/ — cette suite n'embarque pas le sérialiseur). Les GetPlan de cette suite ne + // le sont pas : leurs params sont FABRIQUÉS par le test, le client n'en connaît pas le contenu ; + // ceux du sérialiseur réel sont jugés par `socketscheduler`, `refoptimizer` et `protocolschema`. + const QList handshakes = Contrat::deMethode(FakeOptimizer::capture(), QStringLiteral("Handshake")); + FakeOptimizer::capture().clear(); + if (handshakes.isEmpty()) + return; + const Contrat::Verdict v = Contrat::juger(handshakes, Contrat::schemaDuDepot(), {QStringLiteral("--requests-only")}); + QVERIFY2(v.code == 0 && v.compte(QStringLiteral("Handshake.params")) == handshakes.size(), + qUtf8Printable(QStringLiteral("Handshake du client NON CONFORME au contrat :\n") + v.sortie.right(3000))); +} + void TestOptimizerClient::initTestCase() { QLoggingCategory::setFilterRules(QStringLiteral("NymeaEnergy.debug=true")); diff --git a/tests/auto/refoptimizer/refoptimizer.pro b/tests/auto/refoptimizer/refoptimizer.pro index 1fd1d6a..acff0ff 100644 --- a/tests/auto/refoptimizer/refoptimizer.pro +++ b/tests/auto/refoptimizer/refoptimizer.pro @@ -29,7 +29,8 @@ HEADERS += \ $$SIM/experience/energylogger.h \ $$SIM/experience/energymanagermock.h \ $$SIM/simulationbase.h \ - $$PWD/../common/etm-powersync-fakeoptimizer.h + $$PWD/../common/etm-powersync-fakeoptimizer.h \ + $$PWD/../common/etm-powersync-contrat.h SOURCES += \ $$SIM/experience/energyexperienceenergymock.cpp \ diff --git a/tests/auto/refoptimizer/testrefoptimizer.cpp b/tests/auto/refoptimizer/testrefoptimizer.cpp index 08e0957..84df205 100644 --- a/tests/auto/refoptimizer/testrefoptimizer.cpp +++ b/tests/auto/refoptimizer/testrefoptimizer.cpp @@ -23,6 +23,7 @@ #include "simulationbase.h" #include "experience/energymanagermock.h" #include "../common/etm-powersync-fakeoptimizer.h" +#include "../common/etm-powersync-contrat.h" #include "energysettings.h" #include "etm/energyarbitrator.h" @@ -223,8 +224,13 @@ private slots: void testTracesReplayWithZeroDivergence(); void testSafetyWinsOverTheOptimizer(); void testAnEchoedTokenLeaksNowhereEndToEnd(); + void cleanupTestCase(); private: + //! Lot contrat OpenRPC : la session capturée par l'optimiseur de référence (\c --capture), + //! jugée contre le contrat EMBARQUÉ ; \p complete = réponses comprises, règles sémantiques + //! appliquées (modes « bons » seulement). La capture est consommée. + Contrat::Verdict jugerLaSession(bool complete); void monter(Banc banc); //! Démarre \p ref et y pointe le moteur par la RPC de production ; attend la connexion si //! \p attendrePret. @@ -345,7 +351,9 @@ void TestRefOptimizer::brancher(RefOptimizer &ref, Transport transport, const QS ? QStringLiteral("unix://%1/ref-%2.sock").arg(m_dir.path()).arg(++n) : QStringLiteral("tcp://127.0.0.1:0"); QString erreur; - QVERIFY2(ref.demarrer(ecoute, args, &erreur), qUtf8Printable(erreur)); + // Toute session du MOTEUR avec la référence est capturée, puis jugée contre le contrat. + QVERIFY2(ref.demarrer(ecoute, args + QStringList{QStringLiteral("--capture"), m_dir.path() + QStringLiteral("/capture.ndjson")}, + &erreur), qUtf8Printable(erreur)); QCOMPARE(rpc("SetOptimizerEndpoint", {{"endpoint", ref.endpoint()}}).value("energyError").toString(), QString("EnergyErrorNoError")); SocketScheduler *s = m_arb->socketScheduler(); @@ -597,6 +605,13 @@ void TestRefOptimizer::testNormalReproducesTheRules() .arg(i).arg(surplus.at(i - 1)).arg(cleEtat(b.at(i))).arg(i - 1).arg(cleEtat(a.at(i - 1))); } QVERIFY2(ecarts.isEmpty(), qUtf8Printable(ecarts.join('\n'))); + // Lot contrat OpenRPC : la session ENTIÈRE est conforme — requêtes du moteur, réponses de la + // référence, et chaque plan applicable selon les règles sémantiques du contrat. + ref.arreter(); + const Contrat::Verdict v = jugerLaSession(true); + QVERIFY2(v.code == 0, qUtf8Printable(v.sortie.right(4000))); + QVERIFY2(v.compte("GetPlan.result") >= surplus.count() - 1 && v.compte("Handshake.result") >= 1, + qUtf8Printable(v.sortie.right(1000))); // L'exemple GPL s'annonce `powersync-optimizer` ; `etm-optimizer` est l'optimiseur propriétaire. QCOMPARE(rpc("GetOptimizerStatus").value("name").toString(), QString("powersync-optimizer")); // Régime stable : à la fin de chaque palier, les deux runs coïncident aussi SANS décalage. @@ -772,6 +787,10 @@ void TestRefOptimizer::testAProtocol10OptimizerIsAccepted() QCOMPARE(v.toMap().value("decision").toMap().value("code").toString(), QString(DecisionCode::OptimizerDecision)); const QVariantMap plan = rpc("GetPlan"); QCOMPARE(plan.value("strategy").toString(), QString("optimizer")); + // Lot contrat OpenRPC : un optimiseur 1.0 est conforme au contrat 1.1 (champs 1.1 optionnels). + ref.arreter(); + const Contrat::Verdict v = jugerLaSession(true); + QVERIFY2(v.code == 0 && v.compte("GetPlan.result") >= 2, qUtf8Printable(v.sortie.right(4000))); } void TestRefOptimizer::testTracesReplayWithZeroDivergence() @@ -970,4 +989,33 @@ void TestRefOptimizer::testAnEchoedTokenLeaksNowhereEndToEnd() } QTEST_MAIN(TestRefOptimizer) +// --- Lot contrat OpenRPC ---------------------------------------------------------------------- + +void TestRefOptimizer::cleanupTestCase() +{ + SimulationBase::cleanupTestCase(); + // Tout ce que le MOTEUR a envoyé à la référence, quels que soient les modes : ses requêtes sont + // toujours conformes, même quand la référence est réglée pour mal répondre. + const Contrat::Verdict v = jugerLaSession(false); + QVERIFY2(v.code == 0 || (v.code == -1 && v.sortie.isEmpty()), qUtf8Printable(QStringLiteral("requêtes du moteur NON CONFORMES au contrat :\n") + v.sortie.right(4000))); +} + +Contrat::Verdict TestRefOptimizer::jugerLaSession(bool complete) +{ + QFile f(m_dir.path() + QStringLiteral("/capture.ndjson")); + QList messages; + if (f.open(QIODevice::ReadOnly)) { + for (const QByteArray &ligne : f.readAll().split('\n')) + if (!ligne.trimmed().isEmpty()) + messages << QJsonDocument::fromJson(ligne).object(); + f.close(); + f.remove(); + } + if (messages.isEmpty()) + return Contrat::Verdict(); // code -1 : rien à juger + QTemporaryDir dir; + return Contrat::juger(messages, Contrat::ecrireSchema(dir, OptimizerProtocol::schemaDocumentBytes()), + complete ? QStringList{QStringLiteral("--semantic")} : QStringList{QStringLiteral("--requests-only")}); +} + #include "testrefoptimizer.moc" diff --git a/tests/auto/socketscheduler/socketscheduler.pro b/tests/auto/socketscheduler/socketscheduler.pro index 9da5d33..b881263 100644 --- a/tests/auto/socketscheduler/socketscheduler.pro +++ b/tests/auto/socketscheduler/socketscheduler.pro @@ -15,6 +15,8 @@ CONFIG += testcase TARGET = etm-powersync-socketscheduler DEFINES += ENERGY_SIMULATION +# Lot contrat OpenRPC : le validateur et le contrat de docs/. Surchargée par $ETM_POWERSYNC_SOURCE_DIR. +DEFINES += ETM_POWERSYNC_SOURCE_DIR=\\\"$$clean_path($$PWD/../../..)\\\" SIM = $$PWD/../simulation INCLUDEPATH += $$SIM @@ -28,7 +30,8 @@ HEADERS += \ $$SIM/experience/energylogger.h \ $$SIM/experience/energymanagermock.h \ $$SIM/simulationbase.h \ - $$PWD/../common/etm-powersync-fakeoptimizer.h + $$PWD/../common/etm-powersync-fakeoptimizer.h \ + $$PWD/../common/etm-powersync-contrat.h SOURCES += \ $$SIM/experience/energyexperienceenergymock.cpp \ diff --git a/tests/auto/socketscheduler/testsocketscheduler.cpp b/tests/auto/socketscheduler/testsocketscheduler.cpp index 84c3d71..d9a08f6 100644 --- a/tests/auto/socketscheduler/testsocketscheduler.cpp +++ b/tests/auto/socketscheduler/testsocketscheduler.cpp @@ -9,6 +9,7 @@ #include "simulationbase.h" #include "experience/energymanagermock.h" #include "../common/etm-powersync-fakeoptimizer.h" +#include "../common/etm-powersync-contrat.h" #include "energysettings.h" #include "etm/energyarbitrator.h" @@ -16,6 +17,7 @@ #include "etm/scheduler/optimizerstatus.h" #include "etm/scheduler/publishedplan.h" #include "etm/scheduler/optimizertrace.h" +#include "etm/scheduler/optimizerprotocol.h" #include "etm/types/decisionreason.h" #include @@ -137,8 +139,13 @@ private slots: void testTracerPairsTheResponseByRequestId(); // Brief 4 T7 — correctif A : un token recopié par l'optimiseur ne sort nulle part void testAnEchoedTokenLeaksNowhere(); + // Lot contrat OpenRPC — le moteur parle le contrat qu'il sert + void testTheEngineSpeaksTheContractItServes(); + void cleanupTestCase(); private: + //! Juge contre le contrat EMBARQUÉ tout ce que le moteur a envoyé au faux optimiseur, puis vide. + void jugerLesRequetesDuMoteur(); //! Banc commun ; \p avecOptimiseur faux = rien n'est configuré. void monter(bool avecOptimiseur = true, FakeOptimizer::Transport transport = FakeOptimizer::Unix); //! Un cycle à \p t, compteur à \p compteurW (négatif = export) ; attend le jugement de la @@ -2120,4 +2127,65 @@ void TestSocketScheduler::testAnEchoedTokenLeaksNowhere() } QTEST_MAIN(TestSocketScheduler) +// --- Lot contrat OpenRPC ---------------------------------------------------------------------- + +void TestSocketScheduler::cleanupTestCase() +{ + SimulationBase::cleanupTestCase(); + jugerLesRequetesDuMoteur(); +} + +void TestSocketScheduler::jugerLesRequetesDuMoteur() +{ + // TOUT ce que le moteur a émis dans ce processus — Handshake du client, GetPlan du sérialiseur + // réel sur le contexte réel de l'arbitre —, jugé par le validateur publié contre le contrat + // EMBARQUÉ. Appelé aussi par monter() (qui relance le serveur) : chaque banc est jugé. + const QList requetes = FakeOptimizer::capture(); + FakeOptimizer::capture().clear(); + if (requetes.isEmpty()) + return; + QTemporaryDir dir; + QVERIFY(dir.isValid()); + const Contrat::Verdict v = Contrat::juger(requetes, Contrat::ecrireSchema(dir, OptimizerProtocol::schemaDocumentBytes()), + {QStringLiteral("--requests-only")}); + QVERIFY2(v.code == 0, qUtf8Printable(QStringLiteral("requêtes du moteur NON CONFORMES au contrat embarqué :\n") + + v.sortie.right(4000))); +} + +void TestSocketScheduler::testTheEngineSpeaksTheContractItServes() +{ + // Le contrat SERVI par NymeaEnergy.GetOptimizerProtocol est celui qu'embarque le binaire, et + // celui de docs/ ; et ce que le moteur ENVOIE à un optimiseur y est conforme. Garde de + // pertinence : des Handshake et des GetPlan ont réellement été jugés — un jugement sur une + // capture vide ne dirait rien. + FakeOptimizer::capture().clear(); + monter(); + m_repondre = planValide(); + const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)); + for (int i = 0; i < 3; ++i) + cycle(t0.addSecs(60 * i), -3000); + + const QVariantMap r = rpc("GetOptimizerProtocol"); + QCOMPARE(r.value("protocolVersion").toString(), QString("1.1")); + const QJsonObject servi = QJsonObject::fromVariantMap(r.value("schema").toMap()); + const QJsonObject embarque = QJsonDocument::fromJson(OptimizerProtocol::schemaDocumentBytes()).object(); + QVERIFY(!embarque.isEmpty()); + QCOMPARE(servi.value("openrpc").toString(), QString("1.3.2")); + QVERIFY2(servi == embarque, "GetOptimizerProtocol ne sert pas le contrat embarqué"); + QFile f(Contrat::schemaDuDepot()); + QVERIFY(f.open(QIODevice::ReadOnly)); + QVERIFY2(QJsonDocument::fromJson(f.readAll()).object() == servi, "le contrat servi n'est pas celui de docs/"); + + // Jugé avec le contrat SERVI, pas une copie : c'est celui qu'un auteur d'optimiseur lirait. + QTemporaryDir dir; + QVERIFY(dir.isValid()); + const QList requetes = FakeOptimizer::capture(); + FakeOptimizer::capture().clear(); + const Contrat::Verdict v = Contrat::juger(requetes, Contrat::ecrireSchema(dir, QJsonDocument(servi).toJson()), + {QStringLiteral("--requests-only")}); + QVERIFY2(v.code == 0, qUtf8Printable(v.sortie.right(4000))); + QVERIFY2(v.compte("Handshake.params") >= 1, qUtf8Printable(v.sortie.right(1000))); + QVERIFY2(v.compte("GetPlan.params") >= 3, qUtf8Printable(v.sortie.right(1000))); +} + #include "testsocketscheduler.moc"