testTheSessionMatchesTheContractOffUtc : le moteur sous Europe/Berlin, cadencé en heure locale comme en production, du 2026-10-25 02:58 CEST au retour à 02:00 CET. Aucun échec de requête, et la session passe tools/etm-powersync-schema-check --semantic contre le contrat embarqué. Contre-épreuve, correctif retiré : 8 rpc-error (le constat VM), et le validateur seul refuse chaque params.timestamp sur son motif `pattern`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
988 lines
44 KiB
C++
988 lines
44 KiB
C++
// SPDX-License-Identifier: GPL-3.0-or-later
|
||
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
|
||
//
|
||
// Lot « contrat OpenRPC » — suite de CONFORMITÉ d'un optimiseur quelconque.
|
||
//
|
||
// L'optimiseur à éprouver est lancé par la commande de $ETM_POWERSYNC_OPTIMIZER_CMD (défaut :
|
||
// l'optimiseur de référence du dépôt, mode `normal`), et le VRAI moteur s'y branche : serveur
|
||
// nymea, arbitre, `SocketScheduler`, `OptimizerClient`, adaptateurs, mocks de `simulation`. Rien
|
||
// n'est simulé côté moteur ; c'est son client, son validateur et sa boucle de sécurité qui jugent.
|
||
// Mode d'emploi : README.md à côté.
|
||
//
|
||
// Ce qui est vérifié, une fonction par point (un processus par fonction) :
|
||
// 1. handshake accepté, client branché DIRECTEMENT sur l'optimiseur ;
|
||
// 2. chaque GetPlan répondu sous le délai du moteur (10 s), aucun échec de requête ;
|
||
// 3. la session entière (les deux sens) conforme au contrat OpenRPC EMBARQUÉ, règles
|
||
// 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 ;
|
||
// 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) ;
|
||
// 10. le point 3, le moteur réglé sur un fuseau NON UTC (Europe/Berlin) et cadencé en heure
|
||
// locale comme en production, à travers le passage à l'heure d'hiver.
|
||
//
|
||
// 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
|
||
// cycle, sans échec ni changement d'optimizerAlive, et le point 5 ne garde que L4 (rien à écrêter).
|
||
// Pas de déduction depuis le handshake : `planning` et `capabilities.kinds` ne disent pas si
|
||
// l'optimiseur planifie (README.md).
|
||
//
|
||
// Les points 2 à 5 passent par un RELAIS transparent (une ligne reçue = une ligne renvoyée) : il
|
||
// capture la session pour le validateur, mesure les délais, et pour le point 5 seulement réécrit
|
||
// la consigne d'un plan en 100 kW — l'épreuve doit tenir quel que soit l'optimiseur, y compris un
|
||
// optimiseur qui n'en enverrait jamais.
|
||
//
|
||
// Banc : celui de `refoptimizer` (restreint) — deux charges `etmvariableload` (0–2 000 W ;
|
||
// plancher 800, 3 000 W) et une PAC SG-Ready (P3 1 500 W, P4 3 000 W), compteur = −surplus +
|
||
// Σ consommations, une charge consomme au cycle suivant ce qu'on lui a commandé.
|
||
|
||
#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"
|
||
#include "etm/scheduler/optimizerprotocol.h"
|
||
#include "etm/scheduler/socketscheduler.h"
|
||
|
||
#include <QJsonArray>
|
||
#include <QJsonDocument>
|
||
#include <QLocalServer>
|
||
#include <QLocalSocket>
|
||
#include <QProcess>
|
||
#include <QTcpSocket>
|
||
#include <QTemporaryDir>
|
||
|
||
#include <csignal>
|
||
#include <memory>
|
||
#include <unistd.h>
|
||
|
||
using namespace nymeaserver;
|
||
|
||
namespace {
|
||
|
||
QString variable(const char *nom, const QString &defaut = QString())
|
||
{
|
||
const QByteArray v = qgetenv(nom);
|
||
return v.isNull() ? defaut : QString::fromLocal8Bit(v);
|
||
}
|
||
|
||
//! Un socket vers \p endpoint (unix:// ou tcp://), non connecté ; nullptr si illisible.
|
||
QIODevice *socketVers(const QString &endpoint, QObject *parent)
|
||
{
|
||
const OptimizerEndpoint e = OptimizerEndpoint::parse(endpoint);
|
||
if (e.kind == OptimizerEndpoint::Unix) {
|
||
auto *s = new QLocalSocket(parent);
|
||
s->connectToServer(e.path);
|
||
return s;
|
||
}
|
||
if (e.kind == OptimizerEndpoint::Tcp) {
|
||
auto *s = new QTcpSocket(parent);
|
||
s->connectToHost(e.host, e.port);
|
||
return s;
|
||
}
|
||
return nullptr;
|
||
}
|
||
|
||
bool connecte(QIODevice *s)
|
||
{
|
||
if (auto *l = qobject_cast<QLocalSocket *>(s))
|
||
return l->state() == QLocalSocket::ConnectedState;
|
||
if (auto *t = qobject_cast<QTcpSocket *>(s))
|
||
return t->state() == QAbstractSocket::ConnectedState;
|
||
return false;
|
||
}
|
||
|
||
/*!
|
||
* L'optimiseur à éprouver, en sous-processus, dans son propre GROUPE de processus : la commande
|
||
* passe par `/bin/sh -c`, et l'arrêt doit atteindre ce qu'elle a lancé, pas seulement le shell.
|
||
*/
|
||
class Optimiseur
|
||
{
|
||
public:
|
||
~Optimiseur() { arreter(); }
|
||
|
||
static QString commandeParDefaut()
|
||
{
|
||
return QStringLiteral("exec python3 -I -B '%1/tools/etm-powersync-ref-optimizer/etm-powersync-ref-optimizer' "
|
||
"--listen {endpoint}").arg(Contrat::racineSources());
|
||
}
|
||
|
||
bool demarrer(const QString &endpoint, QString *erreur)
|
||
{
|
||
m_endpoint = endpoint;
|
||
const QString cmd = variable("ETM_POWERSYNC_OPTIMIZER_CMD", commandeParDefaut());
|
||
if (cmd == QLatin1String("-"))
|
||
return attendreEcoute(erreur); // déjà lancé par l'appelant
|
||
m_p = new QProcess;
|
||
QProcessEnvironment env = QProcessEnvironment::systemEnvironment();
|
||
env.insert(QStringLiteral("ETM_POWERSYNC_OPTIMIZER_ENDPOINT"), endpoint);
|
||
env.insert(QStringLiteral("LC_ALL"), QStringLiteral("C.UTF-8"));
|
||
m_p->setProcessEnvironment(env);
|
||
m_p->setProcessChannelMode(QProcess::MergedChannels);
|
||
m_p->setChildProcessModifier([] { ::setpgid(0, 0); });
|
||
m_p->start(QStringLiteral("/bin/sh"), {QStringLiteral("-c"), QString(cmd).replace(QStringLiteral("{endpoint}"), endpoint)});
|
||
if (!m_p->waitForStarted(5000)) {
|
||
*erreur = QStringLiteral("la commande ne démarre pas : ") + m_p->errorString();
|
||
return false;
|
||
}
|
||
return attendreEcoute(erreur);
|
||
}
|
||
|
||
void arreter()
|
||
{
|
||
if (!m_p)
|
||
return;
|
||
if (m_p->state() != QProcess::NotRunning) {
|
||
::kill(-pid_t(m_p->processId()), SIGTERM);
|
||
if (!m_p->waitForFinished(3000)) {
|
||
::kill(-pid_t(m_p->processId()), SIGKILL);
|
||
m_p->waitForFinished(3000);
|
||
}
|
||
}
|
||
m_sortie += m_p->readAll();
|
||
delete m_p;
|
||
m_p = nullptr;
|
||
}
|
||
|
||
QString sortie() { if (m_p) m_sortie += m_p->readAll(); return QString::fromUtf8(m_sortie).right(2000); }
|
||
|
||
private:
|
||
bool attendreEcoute(QString *erreur)
|
||
{
|
||
const int delaiS = variable("ETM_POWERSYNC_OPTIMIZER_START_S", QStringLiteral("15")).toInt();
|
||
QElapsedTimer t;
|
||
t.start();
|
||
while (t.elapsed() < delaiS * 1000) {
|
||
QObject proprietaire;
|
||
QIODevice *s = socketVers(m_endpoint, &proprietaire);
|
||
if (!s) {
|
||
*erreur = QStringLiteral("endpoint illisible : ") + m_endpoint;
|
||
return false;
|
||
}
|
||
waitFor([s] { return connecte(s); }, 200);
|
||
if (connecte(s))
|
||
return true; // la sonde se referme sans handshake : un serveur conforme le tolère
|
||
if (m_p && m_p->state() == QProcess::NotRunning) {
|
||
*erreur = QStringLiteral("l'optimiseur s'est arrêté au démarrage (code %1) : %2")
|
||
.arg(m_p->exitCode()).arg(sortie());
|
||
return false;
|
||
}
|
||
settle(100);
|
||
}
|
||
*erreur = QStringLiteral("rien n'écoute sur %1 après %2 s : %3").arg(m_endpoint).arg(delaiS).arg(sortie());
|
||
return false;
|
||
}
|
||
|
||
QProcess *m_p = nullptr;
|
||
QByteArray m_sortie;
|
||
QString m_endpoint;
|
||
};
|
||
|
||
/*!
|
||
* Relais transparent moteur ↔ optimiseur : chaque ligne passe telle quelle, et entre dans la
|
||
* session capturée (le token, s'il y en a un, y devient des astérisques). \c horsBornes réécrit
|
||
* la consigne des actions `setpoint` des plans reçus — le point 5, et lui seul.
|
||
*/
|
||
class Relais : public QObject
|
||
{
|
||
public:
|
||
Relais(const QString &amont, const QString &chemin, const QString &token)
|
||
: m_amont(amont), m_token(token)
|
||
{
|
||
QLocalServer::removeServer(chemin);
|
||
connect(&m_serveur, &QLocalServer::newConnection, this, [this] {
|
||
while (QLocalSocket *s = m_serveur.nextPendingConnection())
|
||
adopter(s);
|
||
});
|
||
m_ok = m_serveur.listen(chemin);
|
||
}
|
||
|
||
bool ecoute() const { return m_ok; }
|
||
QString endpoint() const { return QStringLiteral("unix://") + m_serveur.fullServerName(); }
|
||
|
||
QList<QJsonObject> session; //!< Les deux sens, dans l'ordre.
|
||
QList<double> latencesMs; //!< GetPlan : envoi → réponse, en ms.
|
||
bool horsBornes = false;
|
||
int reecrites = 0;
|
||
|
||
private:
|
||
void adopter(QLocalSocket *moteur)
|
||
{
|
||
QIODevice *opt = socketVers(m_amont, moteur);
|
||
if (!opt) {
|
||
moteur->abort();
|
||
return;
|
||
}
|
||
auto *tamponMoteur = new QByteArray;
|
||
auto *tamponOpt = new QByteArray;
|
||
auto *enAttente = new QByteArray; // lignes du moteur reçues avant la connexion amont
|
||
connect(moteur, &QObject::destroyed, this, [=] { delete tamponMoteur; delete tamponOpt; delete enAttente; });
|
||
auto versOpt = [=](const QByteArray &ligne) {
|
||
if (connecte(opt))
|
||
opt->write(ligne + '\n');
|
||
else
|
||
enAttente->append(ligne + '\n');
|
||
};
|
||
auto vider = [=] {
|
||
if (!enAttente->isEmpty() && connecte(opt)) {
|
||
opt->write(*enAttente);
|
||
enAttente->clear();
|
||
}
|
||
};
|
||
if (auto *l = qobject_cast<QLocalSocket *>(opt))
|
||
connect(l, &QLocalSocket::connected, this, vider);
|
||
else if (auto *t = qobject_cast<QTcpSocket *>(opt))
|
||
connect(t, &QTcpSocket::connected, this, vider);
|
||
connect(moteur, &QIODevice::readyRead, this, [=] {
|
||
tamponMoteur->append(moteur->readAll());
|
||
int nl;
|
||
while ((nl = tamponMoteur->indexOf('\n')) >= 0) {
|
||
const QByteArray ligne = tamponMoteur->left(nl);
|
||
tamponMoteur->remove(0, nl + 1);
|
||
const QJsonObject m = QJsonDocument::fromJson(ligne).object();
|
||
noter(m);
|
||
if (m.value("method").toString() == QLatin1String("GetPlan")) {
|
||
m_planEnVol = m.value("id").toVariant().toLongLong();
|
||
m_chrono.start();
|
||
}
|
||
versOpt(ligne);
|
||
}
|
||
});
|
||
connect(opt, &QIODevice::readyRead, this, [=] {
|
||
tamponOpt->append(opt->readAll());
|
||
int nl;
|
||
while ((nl = tamponOpt->indexOf('\n')) >= 0) {
|
||
QByteArray ligne = tamponOpt->left(nl);
|
||
tamponOpt->remove(0, nl + 1);
|
||
QJsonObject m = QJsonDocument::fromJson(ligne).object();
|
||
noter(m); // l'ORIGINAL : c'est l'optimiseur qu'on juge, pas la réécriture
|
||
if (m_planEnVol && m.value("id").toVariant().toLongLong() == m_planEnVol) {
|
||
latencesMs << m_chrono.nsecsElapsed() / 1e6;
|
||
m_planEnVol = 0;
|
||
if (horsBornes && m.value("result").isObject()) {
|
||
reecrire(m);
|
||
ligne = QJsonDocument(m).toJson(QJsonDocument::Compact);
|
||
}
|
||
}
|
||
moteur->write(ligne + '\n');
|
||
}
|
||
});
|
||
auto couper = [=] {
|
||
if (auto *l = qobject_cast<QLocalSocket *>(opt)) l->abort();
|
||
else if (auto *t = qobject_cast<QTcpSocket *>(opt)) t->abort();
|
||
moteur->abort();
|
||
};
|
||
connect(moteur, &QLocalSocket::disconnected, this, couper);
|
||
if (auto *l = qobject_cast<QLocalSocket *>(opt))
|
||
connect(l, &QLocalSocket::disconnected, this, couper);
|
||
else if (auto *t = qobject_cast<QTcpSocket *>(opt))
|
||
connect(t, &QTcpSocket::disconnected, this, couper);
|
||
}
|
||
|
||
void reecrire(QJsonObject &m)
|
||
{
|
||
QJsonObject r = m.value("result").toObject();
|
||
QJsonArray creneaux = r.value("slots").toArray();
|
||
for (int i = 0; i < creneaux.size(); ++i) {
|
||
QJsonObject s = creneaux.at(i).toObject();
|
||
QJsonArray actions = s.value("actions").toArray();
|
||
for (int j = 0; j < actions.size(); ++j) {
|
||
QJsonObject a = actions.at(j).toObject();
|
||
if (a.value("kind").toString() != QLatin1String("setpoint"))
|
||
continue;
|
||
if (a.contains("currentA"))
|
||
a.insert("currentA", 500);
|
||
a.insert("powerW", 100000);
|
||
actions[j] = a;
|
||
++reecrites;
|
||
}
|
||
s.insert("actions", actions);
|
||
creneaux[i] = s;
|
||
}
|
||
r.insert("slots", creneaux);
|
||
m.insert("result", r);
|
||
}
|
||
|
||
void noter(QJsonObject m)
|
||
{
|
||
if (!m_token.isEmpty()) {
|
||
QByteArray l = QJsonDocument(m).toJson(QJsonDocument::Compact);
|
||
l.replace(m_token.toUtf8(), QByteArray(m_token.toUtf8().size(), '*'));
|
||
m = QJsonDocument::fromJson(l).object();
|
||
}
|
||
session << m;
|
||
}
|
||
|
||
QLocalServer m_serveur;
|
||
QString m_amont;
|
||
QString m_token;
|
||
bool m_ok = false;
|
||
qint64 m_planEnVol = 0;
|
||
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<QLocalSocket *>(m_s))
|
||
l->flush();
|
||
else if (auto *t = qobject_cast<QTcpSocket *>(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":<id>,"error":{"code":<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();
|
||
}
|
||
|
||
//! Le fuseau de la BOX, posé par le test et rendu en sortant (même garde que `simulation`).
|
||
struct FuseauDeTest {
|
||
QByteArray avant;
|
||
bool avait;
|
||
explicit FuseauDeTest(const QByteArray &tz) : avant(qgetenv("TZ")), avait(qEnvironmentVariableIsSet("TZ"))
|
||
{ qputenv("TZ", tz); }
|
||
~FuseauDeTest() { if (avait) qputenv("TZ", avant); else qunsetenv("TZ"); }
|
||
};
|
||
|
||
QString cle(const QVariantMap &v)
|
||
{
|
||
return QString::fromUtf8(QJsonDocument::fromVariant(v).toJson(QJsonDocument::Compact));
|
||
}
|
||
|
||
} // namespace
|
||
|
||
class TestOptimizerConformance : public SimulationBase
|
||
{
|
||
Q_OBJECT
|
||
|
||
private slots:
|
||
void testHandshakeIsAccepted();
|
||
void testGetPlanIsAnsweredInTime();
|
||
void testTheSessionMatchesTheContract();
|
||
void testEveryPlanIsAccepted();
|
||
void testTheSafetyLoopStaysOnTop();
|
||
void testGetPlanBeforeTheHandshakeIsRefused();
|
||
void testARefusedHandshakeIsClosedByTheOptimizer();
|
||
void testABatchIsRefused();
|
||
void testALineOverTheLimitIsRefused();
|
||
void testTheSessionMatchesTheContractOffUtc();
|
||
|
||
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> &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);
|
||
//! La session capturée par le relais, jugée par le validateur publié (point 3 et point 10).
|
||
void jugerLaSession();
|
||
//! Une série de cycles qui parcourt le banc : import, demi-charge, PAC en 3, puis en 4.
|
||
void parcours(const QDateTime &t0);
|
||
QVariantMap etatApplique() const;
|
||
int etatPac() const;
|
||
QVariantMap rpc(const QString &methode, const QVariantMap ¶ms = QVariantMap());
|
||
QList<QVariantMap> traces() const;
|
||
|
||
//! Ce que la suite attend de l'optimiseur : des plans appliqués, ou une abstention à chaque cycle.
|
||
enum class Attendu { Plan, Abstention };
|
||
Attendu m_attendu = Attendu::Plan;
|
||
EnergyArbitrator *m_arb = nullptr;
|
||
Thing *m_compteur = nullptr;
|
||
QString m_var1, m_var2;
|
||
QUuid m_k1, m_k2;
|
||
double m_surplusW = 0;
|
||
QTemporaryDir m_dir;
|
||
Optimiseur m_optimiseur;
|
||
Relais *m_relais = nullptr;
|
||
QString m_token;
|
||
int m_jugees = 0;
|
||
int m_requetes = 0;
|
||
QStringList m_echecs;
|
||
QStringList m_planRefuse; //!< cycles où l'optimiseur n'a pas commandé, avec le code de repli
|
||
QStringList m_nonAbstenu; //!< mode abstain : cycles qui ne finissent pas en abstention
|
||
QStringList m_verdicts; //!< chaque réponse jugée : « issue/code »
|
||
};
|
||
|
||
void TestOptimizerConformance::monter()
|
||
{
|
||
// Le mode d'abord : un journal doit dire ce qui a été jugé, même si la suite échoue ensuite.
|
||
const QString expect = variable("ETM_POWERSYNC_CONFORMANCE_EXPECT");
|
||
if (expect.isEmpty() || expect == QLatin1String("plan"))
|
||
m_attendu = Attendu::Plan;
|
||
else if (expect == QLatin1String("abstain"))
|
||
m_attendu = Attendu::Abstention;
|
||
else
|
||
QFAIL(qUtf8Printable("ETM_POWERSYNC_CONFORMANCE_EXPECT inconnu : « " + expect + " » (plan | abstain)"));
|
||
qInfo().noquote() << "[Conformité] mode attendu :"
|
||
<< (m_attendu == Attendu::Plan ? "plan" : "abstain")
|
||
<< (expect.isEmpty() ? "(défaut)" : "(ETM_POWERSYNC_CONFORMANCE_EXPECT)");
|
||
|
||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-conformance.json";
|
||
QFile::remove(cfgPath);
|
||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
|
||
cleanupTestCase();
|
||
EnergySettings().remove("Optimizer/endpoint");
|
||
QFile::remove(OptimizerClient::defaultTokenPath());
|
||
for (const char *k : {"Optimizer/trace", "Optimizer/traceRetentionDays", "Optimizer/traceMaxBytesPerDay"})
|
||
EnergySettings().remove(k);
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
m_arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY(m_arb);
|
||
QVERIFY(m_dir.isValid());
|
||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||
const QUuid meterId = addMeter();
|
||
m_experiencePlugin->energyManager()->setRootMeter(meterId);
|
||
m_compteur = tm->findConfiguredThing(meterId);
|
||
QVERIFY(m_compteur);
|
||
m_compteur->setStateValue("connected", true);
|
||
for (const char *p : {"currentPhaseA", "currentPhaseB", "currentPhaseC"})
|
||
m_compteur->setStateValue(p, 0);
|
||
m_var1 = addEtmVariableLoad(27143).toString();
|
||
m_var2 = addEtmVariableLoad(27144).toString();
|
||
m_k1 = addPowerSwitch(0, 26761);
|
||
m_k2 = addPowerSwitch(0, 26762);
|
||
for (const QString &v : {m_var1, m_var2})
|
||
tm->findConfiguredThing(ThingId(v))->setStateValue("currentPowerW", 0);
|
||
m_arb->setPhasePowerLimit(0);
|
||
QVariantList l;
|
||
l.append(QVariantMap{{"id", m_var1}, {"label", "Variable 1"}, {"adapter", "etmvariableload"},
|
||
{"mode", "dynamic"}, {"maxPowerW", 2000}, {"priority", 1}, {"enabled", true}});
|
||
l.append(QVariantMap{{"id", m_var2}, {"label", "Variable 2"}, {"adapter", "etmvariableload"},
|
||
{"mode", "dynamic"}, {"minPowerW", 800}, {"maxPowerW", 3000}, {"priority", 2},
|
||
{"enabled", true}});
|
||
l.append(QVariantMap{
|
||
{"id", "pac"}, {"label", "PAC"}, {"adapter", "sg-ready"}, {"mode", "fixed"}, {"priority", 3},
|
||
{"enabled", true},
|
||
{"sgReady", QVariantMap{
|
||
{"minStateHoldS", 0},
|
||
{"states", QVariantList()
|
||
<< QVariantMap{{"state", 1}, {"relays", QVariantList{m_k1.toString()}}, {"estimatedPowerW", 0.0}}
|
||
<< QVariantMap{{"state", 2}, {"relays", QVariantList()}, {"estimatedPowerW", 0.0}}
|
||
<< QVariantMap{{"state", 3}, {"relays", QVariantList{m_k2.toString()}}, {"estimatedPowerW", 1500.0}}
|
||
<< QVariantMap{{"state", 4}, {"relays", QVariantList{m_k1.toString(), m_k2.toString()}},
|
||
{"estimatedPowerW", 3000.0}}}}}});
|
||
QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", l}}))
|
||
.toMap().value("params").toMap().value("energyError").toString(),
|
||
QString("EnergyErrorNoError"));
|
||
// Traces : elles portent ce que le moteur a EXÉCUTÉ (clamped, shed) — le point 5 les lit.
|
||
const QString d = m_dir.path() + QStringLiteral("/traces");
|
||
QDir().mkpath(d);
|
||
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.
|
||
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 = this->endpoint();
|
||
QString erreur;
|
||
QVERIFY2(m_optimiseur.demarrer(endpoint, &erreur), qUtf8Printable(erreur));
|
||
QString cible = endpoint;
|
||
if (parLeRelais) {
|
||
m_relais = new Relais(endpoint, m_dir.path() + QStringLiteral("/relais.sock"), m_token);
|
||
QVERIFY2(m_relais->ecoute(), "le relais n'écoute pas");
|
||
cible = m_relais->endpoint();
|
||
}
|
||
QCOMPARE(rpc("SetOptimizerEndpoint", {{"endpoint", cible}}).value("energyError").toString(), QString("EnergyErrorNoError"));
|
||
SocketScheduler *s = m_arb->socketScheduler();
|
||
QVERIFY(s);
|
||
connect(s, &SocketScheduler::reponseJugee, this, [this](const QString &issue, const QString &code) {
|
||
++m_jugees;
|
||
m_verdicts << issue + QLatin1Char('/') + code;
|
||
});
|
||
connect(s, &SocketScheduler::echecRequete, this, [this](const QString &code) { ++m_jugees; m_echecs << code; });
|
||
QVERIFY2(waitFor([s] { return s->client()->state() == OptimizerClient::Ready; }, 15000),
|
||
qUtf8Printable(QStringLiteral("le moteur ne passe pas le handshake (lastErrorCode %1) :\n%2")
|
||
.arg(s->client()->lastErrorCode(), m_optimiseur.sortie())));
|
||
}
|
||
|
||
void TestOptimizerConformance::cycle(const QDateTime &t)
|
||
{
|
||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||
double consoW = 0;
|
||
for (const QString &v : {m_var1, m_var2})
|
||
consoW += tm->findConfiguredThing(ThingId(v))->stateValue("currentPowerW").toDouble();
|
||
consoW += QHash<int, double>{{3, 1500.0}, {4, 3000.0}}.value(etatPac(), 0.0);
|
||
m_compteur->setStateValue("currentPower", -m_surplusW + consoW);
|
||
SocketScheduler *s = m_arb->socketScheduler();
|
||
const bool envoi = s && s->client()->state() == OptimizerClient::Ready;
|
||
const int avant = m_jugees;
|
||
m_arb->recordMeterUpdate(t);
|
||
m_arb->simulationCallUpdate(t);
|
||
if (envoi) {
|
||
++m_requetes;
|
||
// 15 s : au-delà des 10 s du moteur, qui conclut lui-même au délai dépassé (echecRequete).
|
||
QVERIFY2(waitFor([&] { return m_jugees > avant; }, 15000), "réponse jamais jugée par le moteur");
|
||
}
|
||
settle(30);
|
||
for (const QString &v : {m_var1, m_var2}) {
|
||
Thing *th = tm->findConfiguredThing(ThingId(v));
|
||
th->setStateValue("currentPowerW", th->stateValue("powerSetpoint").toDouble());
|
||
}
|
||
}
|
||
|
||
void TestOptimizerConformance::parcours(const QDateTime &t0)
|
||
{
|
||
const QList<double> surplus{-1000, 1500, 2600, 4200, 6600, 9000, 3000, -500};
|
||
m_planRefuse.clear();
|
||
m_nonAbstenu.clear();
|
||
const bool vivantAuDepart = rpc("GetOptimizerStatus").value("optimizerAlive").toBool();
|
||
for (int i = 0; i < surplus.count(); ++i) {
|
||
m_surplusW = surplus.at(i);
|
||
cycle(t0.addSecs(30 * i));
|
||
if (QTest::currentTestFailed())
|
||
return;
|
||
// Le premier cycle n'a pas encore de plan en cache (modèle cache) : il ne compte pas.
|
||
if (i == 0)
|
||
continue;
|
||
const QVariantMap p = rpc("GetPlan");
|
||
const QString issue = QStringLiteral("cycle %1 (surplus %2 W) : %3, fallbackCode %4")
|
||
.arg(i).arg(surplus.at(i)).arg(p.value("strategy").toString(),
|
||
p.value("fallbackCode").toString());
|
||
if (!m_arb->socketScheduler()->optimiseurAuDernierCycle() || p.contains("fallbackCode"))
|
||
m_planRefuse << issue;
|
||
// §7 : une abstention rend le cycle au rule-based sans toucher à optimizerAlive.
|
||
const bool vivant = rpc("GetOptimizerStatus").value("optimizerAlive").toBool();
|
||
if (m_arb->socketScheduler()->optimiseurAuDernierCycle() || p.value("strategy").toString() != QLatin1String("rules")
|
||
|| p.value("fallbackCode").toString() != QLatin1String("abstain") || vivant != vivantAuDepart)
|
||
m_nonAbstenu << issue + QStringLiteral(", optimizerAlive %1 → %2").arg(vivantAuDepart ? "true" : "false", vivant ? "true" : "false");
|
||
}
|
||
}
|
||
|
||
int TestOptimizerConformance::etatPac() const
|
||
{
|
||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||
const bool k1 = tm->findConfiguredThing(m_k1)->stateValue("power").toBool();
|
||
const bool k2 = tm->findConfiguredThing(m_k2)->stateValue("power").toBool();
|
||
return k1 ? (k2 ? 4 : 1) : (k2 ? 3 : 2);
|
||
}
|
||
|
||
QVariantMap TestOptimizerConformance::etatApplique() const
|
||
{
|
||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||
return {{"var1", tm->findConfiguredThing(ThingId(m_var1))->stateValue("powerSetpoint").toDouble()},
|
||
{"var2", tm->findConfiguredThing(ThingId(m_var2))->stateValue("powerSetpoint").toDouble()},
|
||
{"pac", etatPac()}};
|
||
}
|
||
|
||
QVariantMap TestOptimizerConformance::rpc(const QString &methode, const QVariantMap ¶ms)
|
||
{
|
||
const QVariantMap r = injectAndWait("NymeaEnergy." + methode, params).toMap();
|
||
[&] { QCOMPARE(r.value("status").toString(), QString("success")); }();
|
||
return r.value("params").toMap();
|
||
}
|
||
|
||
QList<QVariantMap> TestOptimizerConformance::traces() const
|
||
{
|
||
QList<QVariantMap> l;
|
||
const QDir d(m_dir.path() + QStringLiteral("/traces"));
|
||
for (const QString &nom : d.entryList({"etm-powersync-optimizer-trace-*.ndjson"}, QDir::Files, QDir::Name)) {
|
||
QFile f(d.filePath(nom));
|
||
if (f.open(QIODevice::ReadOnly))
|
||
for (const QByteArray &ligne : f.readAll().split('\n'))
|
||
if (!ligne.trimmed().isEmpty())
|
||
l << QJsonDocument::fromJson(ligne).toVariant().toMap();
|
||
}
|
||
return l;
|
||
}
|
||
|
||
// --- 1 ---------------------------------------------------------------------------------------------
|
||
|
||
void TestOptimizerConformance::testHandshakeIsAccepted()
|
||
{
|
||
// Le client du moteur, DIRECTEMENT sur l'optimiseur (sans relais) : connexion, handshake,
|
||
// majeure acceptée ; ce que l'installateur verra (nom, version, capacités) est publié.
|
||
monter();
|
||
if (QTest::currentTestFailed())
|
||
return;
|
||
brancher(false);
|
||
if (QTest::currentTestFailed())
|
||
return;
|
||
const QVariantMap st = rpc("GetOptimizerStatus");
|
||
QVERIFY2(st.value("protocolVersion").toString().section('.', 0, 0) == QLatin1String("1"), qUtf8Printable(cle(st)));
|
||
QVERIFY2(!st.contains("lastErrorCode"), qUtf8Printable(cle(st)));
|
||
qInfo().noquote() << "[Conformité] optimiseur :" << st.value("name").toString() << "protocole"
|
||
<< st.value("protocolVersion").toString() << "capacités" << cle(st.value("capabilities").toMap());
|
||
}
|
||
|
||
// --- 2 ---------------------------------------------------------------------------------------------
|
||
|
||
void TestOptimizerConformance::testGetPlanIsAnsweredInTime()
|
||
{
|
||
monter();
|
||
if (QTest::currentTestFailed())
|
||
return;
|
||
brancher(true);
|
||
if (QTest::currentTestFailed())
|
||
return;
|
||
parcours(utcDateTime(QDate(2026, 6, 8), QTime(9, 0, 0)));
|
||
QVERIFY2(m_echecs.isEmpty(), qUtf8Printable("requêtes en échec : " + m_echecs.join(", ")));
|
||
QCOMPARE(m_relais->latencesMs.size(), m_requetes);
|
||
double max = 0;
|
||
for (double l : m_relais->latencesMs)
|
||
max = qMax(max, l);
|
||
qInfo() << "[Conformité]" << m_requetes << "GetPlan, latence max" << max << "ms";
|
||
QVERIFY2(max < OptimizerClient::kRequestTimeoutMs,
|
||
qUtf8Printable(QStringLiteral("latence max %1 ms ≥ %2 ms").arg(max, 0, 'f', 1).arg(OptimizerClient::kRequestTimeoutMs)));
|
||
}
|
||
|
||
// --- 3 ---------------------------------------------------------------------------------------------
|
||
|
||
void TestOptimizerConformance::testTheSessionMatchesTheContract()
|
||
{
|
||
monter();
|
||
if (QTest::currentTestFailed())
|
||
return;
|
||
brancher(true);
|
||
if (QTest::currentTestFailed())
|
||
return;
|
||
parcours(utcDateTime(QDate(2026, 6, 8), QTime(9, 0, 0)));
|
||
jugerLaSession();
|
||
}
|
||
|
||
void TestOptimizerConformance::jugerLaSession()
|
||
{
|
||
QTemporaryDir dir;
|
||
QVERIFY(dir.isValid());
|
||
const Contrat::Verdict v = Contrat::juger(m_relais->session,
|
||
Contrat::ecrireSchema(dir, OptimizerProtocol::schemaDocumentBytes()),
|
||
{QStringLiteral("--semantic")});
|
||
QVERIFY2(v.code == 0, qUtf8Printable(QStringLiteral("session NON CONFORME au contrat :\n") + v.sortie.right(4000)));
|
||
QVERIFY2(v.compte("Handshake.result") >= 1 && v.compte("GetPlan.result") >= m_requetes - 1,
|
||
qUtf8Printable(v.sortie.right(1000)));
|
||
}
|
||
|
||
// --- 4 ---------------------------------------------------------------------------------------------
|
||
|
||
void TestOptimizerConformance::testEveryPlanIsAccepted()
|
||
{
|
||
monter();
|
||
if (QTest::currentTestFailed())
|
||
return;
|
||
brancher(true);
|
||
if (QTest::currentTestFailed())
|
||
return;
|
||
parcours(utcDateTime(QDate(2026, 6, 8), QTime(9, 0, 0)));
|
||
if (m_attendu == Attendu::Abstention) {
|
||
if (QTest::currentTestFailed())
|
||
return;
|
||
// Chaque cycle rendu au rule-based PAR UNE ABSTENTION : ni plan appliqué, ni autre repli,
|
||
// ni échec compté (§7). Une réponse jugée autrement qu'`abstention/abstain` fait échouer.
|
||
QVERIFY2(m_nonAbstenu.isEmpty(), qUtf8Printable(QStringLiteral("cycles non abstenus :\n") + m_nonAbstenu.join('\n')));
|
||
QVERIFY2(m_echecs.isEmpty(), qUtf8Printable("requêtes en échec : " + m_echecs.join(", ")));
|
||
QVERIFY2(!m_verdicts.isEmpty() && m_verdicts.count(QStringLiteral("abstention/abstain")) == m_verdicts.size(),
|
||
qUtf8Printable("réponses jugées : " + m_verdicts.join(", ")));
|
||
QCOMPARE(m_arb->socketScheduler()->vivacite().echecsConsecutifs(), 0);
|
||
QCOMPARE(rpc("GetOptimizerStatus").value("activeStrategy").toString(), QString("rules"));
|
||
return;
|
||
}
|
||
QVERIFY2(m_planRefuse.isEmpty(), qUtf8Printable(QStringLiteral("plans non appliqués :\n") + m_planRefuse.join('\n')));
|
||
QCOMPARE(rpc("GetOptimizerStatus").value("activeStrategy").toString(), QString("optimizer"));
|
||
}
|
||
|
||
// --- 5 ---------------------------------------------------------------------------------------------
|
||
|
||
void TestOptimizerConformance::testTheSafetyLoopStaysOnTop()
|
||
{
|
||
monter();
|
||
if (QTest::currentTestFailed())
|
||
return;
|
||
m_arb->setPhasePowerLimit(25);
|
||
brancher(true);
|
||
if (QTest::currentTestFailed())
|
||
return;
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(9, 0, 0));
|
||
m_surplusW = 9000;
|
||
for (int k = 0; k < 3; ++k)
|
||
cycle(t0.addSecs(30 * k));
|
||
|
||
// a) Hors bornes : chaque consigne d'un plan reçu devient 100 kW. L'adaptateur l'écrête à la
|
||
// puissance déclarée de la charge, et le moteur le dit (`clamped` dans la trace).
|
||
// En mode abstain, aucun plan donc aucune consigne : les mêmes cycles passent, sans épreuve.
|
||
const bool abstention = m_attendu == Attendu::Abstention;
|
||
m_relais->horsBornes = !abstention;
|
||
for (int k = 3; k < 6; ++k)
|
||
cycle(t0.addSecs(30 * k));
|
||
m_relais->horsBornes = false;
|
||
QList<QVariantMap> l;
|
||
if (!abstention) {
|
||
QVERIFY2(m_relais->reecrites > 0, "l'optimiseur n'a émis aucune consigne setpoint : rien à écrêter");
|
||
const QVariantMap e = etatApplique();
|
||
QVERIFY2(e.value("var1").toDouble() <= 2000 && e.value("var2").toDouble() <= 3000, qUtf8Printable(cle(e)));
|
||
QVERIFY(waitFor([&] { l = traces(); return l.count() >= 6; }, 5000));
|
||
bool ecretee = false;
|
||
for (const QVariant &x : l.at(5).value("executed").toList())
|
||
ecretee |= x.toMap().value("clamped").toBool() && x.toMap().value("powerW").toDouble() <= 3000;
|
||
QVERIFY2(ecretee && l.at(5).value("strategy").toString() == QLatin1String("optimizer"),
|
||
qUtf8Printable(cle(l.at(5))));
|
||
}
|
||
|
||
// b) L4 : phase A à 32 A sous un disjoncteur de 25 A. Quoi que commande l'optimiseur, L4 déleste.
|
||
const QVariantMap avant = etatApplique();
|
||
m_compteur->setStateValue("currentPhaseA", 32);
|
||
m_surplusW = -2000;
|
||
cycle(t0.addSecs(30 * 6));
|
||
const QVariantMap apres = etatApplique();
|
||
QVERIFY(waitFor([&] { l = traces(); return l.count() >= 7; }, 5000));
|
||
bool delestee = false;
|
||
for (const QVariant &x : l.at(6).value("executed").toList())
|
||
delestee |= x.toMap().value("shed").toBool();
|
||
QVERIFY2(delestee || apres.value("var1").toDouble() + apres.value("var2").toDouble()
|
||
< avant.value("var1").toDouble() + avant.value("var2").toDouble(),
|
||
qUtf8Printable(cle(l.at(6))));
|
||
if (abstention) {
|
||
// L4 sous le rule-based, sans consigne de l'optimiseur. Le `shed` est exigé : à −2 000 W, le
|
||
// rule-based baisse déjà les consignes de lui-même, la baisse seule ne prouverait pas L4.
|
||
QVERIFY2(delestee, qUtf8Printable("L4 n'a rien délesté : " + cle(l.at(6))));
|
||
qInfo().noquote() << "[Conformité] L4 vérifié sous" << l.at(6).value("strategy").toString() << "(shed dans la trace)";
|
||
QSKIP("aucune consigne à écrêter : l'optimiseur s'abstient");
|
||
}
|
||
}
|
||
|
||
// --- 6 à 9 : protocole 1.2, par une connexion de sonde ----------------------------------------------
|
||
|
||
void TestOptimizerConformance::sonder(std::unique_ptr<Sonde> &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> 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> 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> 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> 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));
|
||
}
|
||
|
||
// --- 10 --------------------------------------------------------------------------------------------
|
||
|
||
void TestOptimizerConformance::testTheSessionMatchesTheContractOffUtc()
|
||
{
|
||
// Constat VM du 2026-10-21 : sous Europe/Berlin, l'optimiseur conforme refusait CHAQUE GetPlan
|
||
// (-32602, `params.timestamp` sans décalage), et les points 2 à 5 ne pouvaient pas le voir — ils
|
||
// cadencent le moteur en UTC. Ici, le fuseau de la box n'est pas UTC et les cycles sont en
|
||
// Qt::LocalTime, comme `QDateTime::currentDateTime()` en production. Le parcours démarre à
|
||
// 02:58 CEST le 2026-10-25 et traverse le retour à 02:00 CET : les deux décalages, et l'heure
|
||
// ambiguë, passent sur le fil.
|
||
FuseauDeTest fuseau("Europe/Berlin");
|
||
monter();
|
||
if (QTest::currentTestFailed())
|
||
return;
|
||
brancher(true);
|
||
if (QTest::currentTestFailed())
|
||
return;
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 10, 25), QTime(0, 58, 0)).toLocalTime();
|
||
QCOMPARE(t0.timeSpec(), Qt::LocalTime);
|
||
QCOMPARE(t0.offsetFromUtc(), 7200);
|
||
QCOMPARE(t0.addSecs(30 * 7).offsetFromUtc(), 3600);
|
||
parcours(t0);
|
||
if (QTest::currentTestFailed())
|
||
return;
|
||
// Le jugement de l'optimiseur d'abord : un refus du contexte se lit ici, avec son code.
|
||
QVERIFY2(m_echecs.isEmpty(), qUtf8Printable("requêtes en échec : " + m_echecs.join(", ")));
|
||
jugerLaSession();
|
||
}
|
||
|
||
QTEST_MAIN(TestOptimizerConformance)
|
||
#include "testconformance.moc"
|