test: les requêtes réelles du moteur jugées contre le contrat

optimizerclient : chaque Handshake envoyé ; socketscheduler : tout ce que le
moteur envoie au faux optimiseur, plus testTheEngineSpeaksTheContractItServes
(GetOptimizerProtocol == embarqué == docs, requêtes jugées avec le contrat
servi) ; refoptimizer : session capturée par la référence, requêtes toujours,
session entière et règles sémantiques en normal et en 1.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-10-17 21:30:44 +02:00
parent d95a65cb71
commit 07cb44d35f
7 changed files with 154 additions and 4 deletions

View File

@ -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<QJsonObject> &capture()
{
static QList<QJsonObject> 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<bool(const QJsonObject &)> 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);
}

View File

@ -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

View File

@ -7,6 +7,7 @@
#include "etm/scheduler/optimizerclient.h"
#include "../common/etm-powersync-fakeoptimizer.h"
#include "../common/etm-powersync-contrat.h"
#include <QCoreApplication>
#include <QDir>
@ -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<QJsonObject> 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"));

View File

@ -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 \

View File

@ -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<QJsonObject> 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"

View File

@ -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 \

View File

@ -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 <QFileInfo>
@ -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<QJsonObject> 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<QJsonObject> 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"