Constat `drop` de T7 : SocketScheduler notait ConnexionPerdue à CHAQUE état non connecté du client (Connecting, Waiting d'une tentative), réécrivait lastErrorCode avec celui du client et émettait etatChange() à chaque tentative au backoff (une par minute au plafond). La perte se note désormais à la transition : sortie de Ready ou de Handshaking (fermeture, handshake refusé), ou premier échec d'un client qui n'a jamais été connecté (sinon un optimiseur absent dès le départ n'aurait jamais de lastErrorCode). Les tentatives suivantes d'une panne déjà notée ne touchent ni au dernier code, ni aux échecs, ni à l'état publié. Publication seule : aucune décision ne change. Test socketscheduler::testAReconnectionAttemptIsNotANewLoss : optimiseur disparu, 3 tentatives au backoff → 1 seul etatChange, 1 seule notification, lastErrorCode `connection-lost` (le client en est à `connect-failed`) ; au retour, les transitions du §7 et rien d'autre. Ligne `drop` de refoptimizer : le dernier code reste `disconnected`. Contre-épreuve M2 (ancien comportement) : mord sur les deux. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
954 lines
44 KiB
C++
954 lines
44 KiB
C++
// SPDX-License-Identifier: GPL-3.0-or-later
|
||
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
|
||
//
|
||
// Brief 4 T7 — tests d'intégration de fin de lot (§3). L'arbitre RÉEL (serveur nymea, mocks de
|
||
// `simulation`, cycles injectés en temps simulé) parle à l'optimiseur de référence RÉEL,
|
||
// tools/etm-powersync-ref-optimizer, lancé en sous-processus Python par chaque test, sur
|
||
// `unix://` et sur `tcp://` (boucle locale du réseau privé du test).
|
||
//
|
||
// Deux bancs :
|
||
// - RESTREINT (décision Q6) : deux charges etmvariableload et une PAC SG-Ready, la passe confort
|
||
// seule — ni obligation éco, ni dérogation, ni stockage, ni plafond de soutirage. C'est le
|
||
// périmètre où l'optimiseur de référence peut reproduire le rule-based depuis le seul contexte
|
||
// GetPlan (voir l'en-tête de l'outil pour ce que le contexte 1.1 ne porte pas) ;
|
||
// - COMPLET : le même, plus un chauffe-eau relay-router à deux relais sous obligation éco et un
|
||
// plafond de soutirage. Les modes de défaut y sont joués EN ENTIER : ils ne dépendent pas des
|
||
// décisions, et le repli rule-based y travaille sur tout ce qu'il sait faire.
|
||
//
|
||
// Physique du banc : chaque charge consomme au cycle suivant ce qu'on lui a commandé, et le
|
||
// compteur en tient compte (compteur = −surplus + Σ consommations). C'est le régime où le budget
|
||
// du rule-based vaut exactement le surplus, et où un plan calculé sur la requête N, appliqué au
|
||
// cycle N + 1, est comparable à la décision du rule-based au cycle N (modèle cache, T3).
|
||
|
||
#include "simulationbase.h"
|
||
#include "experience/energymanagermock.h"
|
||
#include "../common/etm-powersync-fakeoptimizer.h"
|
||
|
||
#include "energysettings.h"
|
||
#include "etm/energyarbitrator.h"
|
||
#include "etm/scheduler/socketscheduler.h"
|
||
#include "etm/scheduler/optimizerstatus.h"
|
||
#include "etm/scheduler/optimizertrace.h"
|
||
#include "etm/types/decisionreason.h"
|
||
|
||
#include <QFileInfo>
|
||
#include <QJsonArray>
|
||
#include <QJsonDocument>
|
||
#include <QLoggingCategory>
|
||
#include <QProcess>
|
||
#include <QRandomGenerator>
|
||
#include <QSignalSpy>
|
||
#include <QTemporaryDir>
|
||
|
||
#include <experiences/experiencemanager.h>
|
||
#include <nymeasettings.h>
|
||
|
||
using namespace nymeaserver;
|
||
|
||
namespace {
|
||
|
||
//! Capture du journal le temps d'une portée (même principe que `socketscheduler`).
|
||
class Journal
|
||
{
|
||
public:
|
||
Journal() { s_courant = this; s_origine = qInstallMessageHandler(&Journal::collecte); }
|
||
~Journal() { qInstallMessageHandler(s_origine); s_courant = nullptr; }
|
||
QString texte() const { return m_texte; }
|
||
int compte(const QString &fragment) const { return m_texte.count(fragment); }
|
||
QStringList lignes(const QString &fragment) const
|
||
{
|
||
QStringList l;
|
||
for (const QString &x : m_lignes)
|
||
if (x.contains(fragment))
|
||
l << x;
|
||
return l;
|
||
}
|
||
|
||
private:
|
||
static void collecte(QtMsgType type, const QMessageLogContext &ctx, const QString &msg)
|
||
{
|
||
if (s_courant) {
|
||
s_courant->m_texte += msg + QLatin1Char('\n');
|
||
s_courant->m_lignes << QString::fromLatin1(ctx.category ? ctx.category : "?") + QStringLiteral(": ") + msg;
|
||
}
|
||
if (s_origine)
|
||
s_origine(type, ctx, msg);
|
||
}
|
||
QString m_texte;
|
||
QStringList m_lignes;
|
||
static Journal *s_courant;
|
||
static QtMessageHandler s_origine;
|
||
};
|
||
Journal *Journal::s_courant = nullptr;
|
||
QtMessageHandler Journal::s_origine = nullptr;
|
||
|
||
QString racineSources()
|
||
{
|
||
const QByteArray env = qgetenv("ETM_POWERSYNC_SOURCE_DIR");
|
||
return env.isEmpty() ? QStringLiteral(ETM_POWERSYNC_SOURCE_DIR) : QString::fromLocal8Bit(env);
|
||
}
|
||
|
||
/*!
|
||
* L'optimiseur de référence en sous-processus. Rien ne bloque sans borne : le démarrage attend
|
||
* la ligne « ÉCOUTE <endpoint> » au plus \c timeoutMs, puis échoue en disant pourquoi (sortie
|
||
* d'erreur du processus) ; l'arrêt envoie SIGTERM, puis SIGKILL après 3 s.
|
||
*/
|
||
class RefOptimizer
|
||
{
|
||
public:
|
||
~RefOptimizer() { arreter(); }
|
||
|
||
static QString script()
|
||
{
|
||
return racineSources() + QStringLiteral("/tools/etm-powersync-ref-optimizer/etm-powersync-ref-optimizer");
|
||
}
|
||
|
||
bool demarrer(const QString &ecoute, const QStringList &args, QString *erreur,
|
||
const QString &interpreteur = QStringLiteral("python3"), int timeoutMs = 10000)
|
||
{
|
||
arreter();
|
||
m_sortie.clear();
|
||
m_erreurs.clear();
|
||
m_endpoint.clear();
|
||
m_p = new QProcess;
|
||
QProcessEnvironment env = QProcessEnvironment::systemEnvironment();
|
||
env.insert(QStringLiteral("LC_ALL"), QStringLiteral("C.UTF-8"));
|
||
m_p->setProcessEnvironment(env);
|
||
m_p->setProgram(interpreteur);
|
||
m_p->setArguments(QStringList{QStringLiteral("-I"), QStringLiteral("-B"), script(),
|
||
QStringLiteral("--listen"), ecoute} + args);
|
||
m_p->start();
|
||
QElapsedTimer t;
|
||
t.start();
|
||
if (!m_p->waitForStarted(qMin(timeoutMs, 5000))) {
|
||
*erreur = QStringLiteral("l'interpréteur « %1 » ne démarre pas : %2").arg(interpreteur, m_p->errorString());
|
||
arreter();
|
||
return false;
|
||
}
|
||
while (t.elapsed() < timeoutMs) {
|
||
m_p->waitForReadyRead(50);
|
||
m_sortie += m_p->readAllStandardOutput();
|
||
m_erreurs += m_p->readAllStandardError();
|
||
const int i = m_sortie.indexOf(QByteArrayLiteral("ÉCOUTE "));
|
||
const int fin = i < 0 ? -1 : m_sortie.indexOf('\n', i);
|
||
if (fin > 0) {
|
||
m_endpoint = QString::fromUtf8(m_sortie.mid(i + int(qstrlen("ÉCOUTE ")), fin - i - int(qstrlen("ÉCOUTE "))));
|
||
return true;
|
||
}
|
||
if (m_p->state() == QProcess::NotRunning) {
|
||
m_erreurs += m_p->readAllStandardError();
|
||
*erreur = QStringLiteral("l'optimiseur de référence s'est arrêté au démarrage (code %1) : %2")
|
||
.arg(m_p->exitCode()).arg(QString::fromUtf8(m_erreurs).trimmed().right(600));
|
||
arreter();
|
||
return false;
|
||
}
|
||
}
|
||
*erreur = QStringLiteral("l'optimiseur de référence n'écoute pas après %1 ms : %2")
|
||
.arg(timeoutMs).arg(QString::fromUtf8(m_erreurs).trimmed().right(600));
|
||
arreter();
|
||
return false;
|
||
}
|
||
|
||
void arreter()
|
||
{
|
||
if (!m_p)
|
||
return;
|
||
if (m_p->state() != QProcess::NotRunning) {
|
||
m_p->terminate();
|
||
if (!m_p->waitForFinished(3000)) {
|
||
m_p->kill();
|
||
m_p->waitForFinished(3000);
|
||
}
|
||
}
|
||
m_erreurs += m_p->readAllStandardError();
|
||
delete m_p;
|
||
m_p = nullptr;
|
||
}
|
||
|
||
QString endpoint() const { return m_endpoint; }
|
||
QString journal() { if (m_p) m_erreurs += m_p->readAllStandardError(); return QString::fromUtf8(m_erreurs); }
|
||
|
||
private:
|
||
QProcess *m_p = nullptr;
|
||
QByteArray m_sortie;
|
||
QByteArray m_erreurs;
|
||
QString m_endpoint;
|
||
};
|
||
|
||
//! Un processus Python qui rejoue des traces : borné, code de sortie et sorties rendus.
|
||
int lancerRejeu(const QStringList &args, QString *sortie, int timeoutMs = 60000)
|
||
{
|
||
QProcess p;
|
||
p.setProgram(QStringLiteral("python3"));
|
||
p.setArguments(QStringList{QStringLiteral("-I"), QStringLiteral("-B"),
|
||
racineSources() + QStringLiteral("/tools/etm-powersync-trace-replay")} + args);
|
||
p.start();
|
||
if (!p.waitForStarted(5000)) {
|
||
*sortie = p.errorString();
|
||
return -1;
|
||
}
|
||
if (!p.waitForFinished(timeoutMs)) {
|
||
p.kill();
|
||
p.waitForFinished(3000);
|
||
*sortie = QStringLiteral("rejeu toujours en cours après %1 ms — tué").arg(timeoutMs);
|
||
return -2;
|
||
}
|
||
*sortie = QString::fromUtf8(p.readAllStandardOutput() + p.readAllStandardError());
|
||
return p.exitCode();
|
||
}
|
||
|
||
QString cleEtat(const QVariantMap &etat)
|
||
{
|
||
return QString::fromUtf8(QJsonDocument::fromVariant(etat).toJson(QJsonDocument::Compact));
|
||
}
|
||
|
||
} // namespace
|
||
|
||
class TestRefOptimizer : public SimulationBase
|
||
{
|
||
Q_OBJECT
|
||
|
||
public:
|
||
enum class Banc { Restreint, Complet };
|
||
enum class Transport { Unix, Tcp };
|
||
|
||
private slots:
|
||
void testTheCatalogueMatchesTheEngine();
|
||
void testAPythonThatDoesNotStartFailsClearly();
|
||
void testNormalReproducesTheRules_data();
|
||
void testNormalReproducesTheRules();
|
||
void testEachFaultModeGivesItsRow_data();
|
||
void testEachFaultModeGivesItsRow();
|
||
void testAProtocol10OptimizerIsAccepted();
|
||
void testTracesReplayWithZeroDivergence();
|
||
void testSafetyWinsOverTheOptimizer();
|
||
void testAnEchoedTokenLeaksNowhereEndToEnd();
|
||
|
||
private:
|
||
void monter(Banc banc);
|
||
//! Démarre \p ref et y pointe le moteur par la RPC de production ; attend la connexion si
|
||
//! \p attendrePret.
|
||
void brancher(RefOptimizer &ref, Transport transport, const QStringList &args, bool attendrePret = true);
|
||
//! Un cycle à \p t sous le surplus courant ; attend le jugement de la réponse si une requête
|
||
//! part et que \p attendreReponse. Puis les charges se mettent à consommer leur consigne.
|
||
void cycle(const QDateTime &t, bool attendreReponse = true);
|
||
//! Ce que les MOCKS ont reçu : consigne de chaque charge en watts, état SG-Ready des contacts.
|
||
QVariantMap etatApplique() const;
|
||
int etatPac() const;
|
||
//! « stratégie|vivant|échecs|dernier code » publiés par GetOptimizerStatus.
|
||
QString ligneStatut();
|
||
QString triplet() const;
|
||
static QStringList triplets(const QSignalSpy &spy);
|
||
QVariantMap rpc(const QString &methode, const QVariantMap ¶ms = QVariantMap());
|
||
void ecrireMode(const QString &mode);
|
||
QString activerTraces();
|
||
QByteArray toutesLesTraces(const QString &dossier) const;
|
||
static QList<QVariantMap> traces(const QString &dossier);
|
||
|
||
EnergyArbitrator *m_arb = nullptr;
|
||
Thing *m_compteur = nullptr;
|
||
QString m_var1, m_var2;
|
||
QUuid m_k1, m_k2, m_k3, m_k4;
|
||
Banc m_banc = Banc::Restreint;
|
||
double m_surplusW = 0;
|
||
QTemporaryDir m_dir;
|
||
QString m_fichierMode;
|
||
int m_jugees = 0;
|
||
QStringList m_codesEchec;
|
||
qint64 m_updateMaxMs = 0;
|
||
QMetaObject::Connection m_cJugee, m_cEchec;
|
||
};
|
||
|
||
// --- Banc --------------------------------------------------------------------------------------
|
||
|
||
void TestRefOptimizer::monter(Banc banc)
|
||
{
|
||
m_banc = banc;
|
||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-refoptimizer.json";
|
||
QFile::remove(cfgPath);
|
||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
|
||
cleanupTestCase();
|
||
EnergySettings().remove("Optimizer/endpoint");
|
||
QFile::remove(OptimizerClient::defaultTokenPath());
|
||
for (const char *cle : {"Optimizer/trace", "Optimizer/traceRetentionDays", "Optimizer/traceMaxBytesPerDay"})
|
||
EnergySettings().remove(cle);
|
||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||
initTestCase();
|
||
m_arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||
QVERIFY(m_arb);
|
||
QVERIFY(m_dir.isValid());
|
||
m_fichierMode = m_dir.path() + "/mode";
|
||
QFile::remove(m_fichierMode);
|
||
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(27043).toString();
|
||
m_var2 = addEtmVariableLoad(27044).toString();
|
||
m_k1 = addPowerSwitch(0, 26661);
|
||
m_k2 = addPowerSwitch(0, 26662);
|
||
for (const QString &v : {m_var1, m_var2}) {
|
||
Thing *t = tm->findConfiguredThing(ThingId(v));
|
||
QVERIFY(t);
|
||
t->setStateValue("currentPowerW", 0);
|
||
}
|
||
|
||
// Passe confort seule : ni besoin, ni dérogation, ni stockage, ni plafond (limite de phase 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}}}}}});
|
||
if (banc == Banc::Complet) {
|
||
// Le reste de ce que sait le rule-based : un chauffe-eau à paliers sous obligation éco, et
|
||
// un plafond de soutirage (disjoncteur 25 A).
|
||
m_k3 = addPowerSwitch(1000, 26663);
|
||
m_k4 = addPowerSwitch(1500, 26664);
|
||
l.append(QVariantMap{{"id", "ecs"}, {"label", "ECS"}, {"adapter", "relay-router"}, {"mode", "fixed"},
|
||
{"priority", 4}, {"enabled", true},
|
||
{"relays", QVariantList{QVariantMap{{"thingId", m_k3.toString()}, {"powerW", 1000}},
|
||
QVariantMap{{"thingId", m_k4.toString()}, {"powerW", 1500}}}},
|
||
{"needs", QVariantMap{{"minEnergyWhPerDay", 3000}, {"dailyDeadline", "18:00"}}}});
|
||
m_arb->setPhasePowerLimit(25);
|
||
}
|
||
QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", l}}))
|
||
.toMap().value("params").toMap().value("energyError").toString(),
|
||
QString("EnergyErrorNoError"));
|
||
m_surplusW = 0;
|
||
m_jugees = 0;
|
||
m_codesEchec.clear();
|
||
m_updateMaxMs = 0;
|
||
}
|
||
|
||
void TestRefOptimizer::brancher(RefOptimizer &ref, Transport transport, const QStringList &args, bool attendrePret)
|
||
{
|
||
static int n = 0;
|
||
const QString ecoute = transport == Transport::Unix
|
||
? 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));
|
||
QCOMPARE(rpc("SetOptimizerEndpoint", {{"endpoint", ref.endpoint()}}).value("energyError").toString(),
|
||
QString("EnergyErrorNoError"));
|
||
SocketScheduler *s = m_arb->socketScheduler();
|
||
QVERIFY(s);
|
||
disconnect(m_cJugee);
|
||
disconnect(m_cEchec);
|
||
m_cJugee = connect(s, &SocketScheduler::reponseJugee, this, [this](const QString &, const QString &) { ++m_jugees; });
|
||
m_cEchec = connect(s, &SocketScheduler::echecRequete, this, [this](const QString &code) {
|
||
++m_jugees;
|
||
m_codesEchec.append(code);
|
||
});
|
||
if (attendrePret)
|
||
QVERIFY2(waitFor([s] { return s->client()->state() == OptimizerClient::Ready; }, 10000),
|
||
qUtf8Printable("le moteur ne se connecte pas à l'optimiseur de référence :\n" + ref.journal()));
|
||
}
|
||
|
||
void TestRefOptimizer::cycle(const QDateTime &t, bool attendreReponse)
|
||
{
|
||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||
// Le compteur voit ce que les charges consomment DÉJÀ (consignes du cycle précédent).
|
||
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);
|
||
if (m_banc == Banc::Complet)
|
||
for (const auto &[k, w] : {std::pair{m_k3, 1000.0}, std::pair{m_k4, 1500.0}})
|
||
if (tm->findConfiguredThing(k)->stateValue("power").toBool())
|
||
consoW += w;
|
||
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);
|
||
QElapsedTimer chrono;
|
||
chrono.start();
|
||
m_arb->simulationCallUpdate(t);
|
||
m_updateMaxMs = qMax(m_updateMaxMs, chrono.elapsed());
|
||
if (envoi && attendreReponse)
|
||
QVERIFY2(waitFor([&] { return m_jugees > avant; }, 15000), "réponse de l'optimiseur de référence jamais jugée");
|
||
settle(30);
|
||
// Physique : chaque charge consomme désormais sa consigne.
|
||
for (const QString &v : {m_var1, m_var2}) {
|
||
Thing *th = tm->findConfiguredThing(ThingId(v));
|
||
th->setStateValue("currentPowerW", th->stateValue("powerSetpoint").toDouble());
|
||
}
|
||
}
|
||
|
||
int TestRefOptimizer::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 TestRefOptimizer::etatApplique() const
|
||
{
|
||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||
QVariantMap e;
|
||
e.insert("var1", tm->findConfiguredThing(ThingId(m_var1))->stateValue("powerSetpoint").toDouble());
|
||
e.insert("var2", tm->findConfiguredThing(ThingId(m_var2))->stateValue("powerSetpoint").toDouble());
|
||
e.insert("pac", etatPac());
|
||
if (m_banc == Banc::Complet)
|
||
e.insert("ecs", tm->findConfiguredThing(m_k3)->stateValue("power").toBool() * 1000
|
||
+ tm->findConfiguredThing(m_k4)->stateValue("power").toBool() * 1500);
|
||
return e;
|
||
}
|
||
|
||
QString TestRefOptimizer::ligneStatut()
|
||
{
|
||
const QVariantMap st = rpc("GetOptimizerStatus");
|
||
return QStringLiteral("%1|%2|%3|%4").arg(st.value("activeStrategy").toString(),
|
||
st.value("optimizerAlive").toBool() ? "vivant" : "mort",
|
||
st.value("consecutiveFailures").toString(),
|
||
st.value("lastErrorCode").toString());
|
||
}
|
||
|
||
QString TestRefOptimizer::triplet() const
|
||
{
|
||
const QVariantMap st = m_arb->optimizerStatus();
|
||
return QStringLiteral("%1|%2|%3").arg(st.value("optimizerExpected").toBool() ? "attendu" : "-",
|
||
st.value("optimizerAlive").toBool() ? "vivant" : "mort",
|
||
st.value("activeStrategy").toString());
|
||
}
|
||
|
||
QStringList TestRefOptimizer::triplets(const QSignalSpy &spy)
|
||
{
|
||
QStringList l;
|
||
for (const QList<QVariant> &args : spy) {
|
||
const QVariantMap st = args.first().toMap();
|
||
l << QStringLiteral("%1|%2|%3").arg(st.value("optimizerExpected").toBool() ? "attendu" : "-",
|
||
st.value("optimizerAlive").toBool() ? "vivant" : "mort",
|
||
st.value("activeStrategy").toString());
|
||
}
|
||
return l;
|
||
}
|
||
|
||
QVariantMap TestRefOptimizer::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();
|
||
}
|
||
|
||
void TestRefOptimizer::ecrireMode(const QString &mode)
|
||
{
|
||
QFile f(m_fichierMode);
|
||
QVERIFY(f.open(QIODevice::WriteOnly | QIODevice::Truncate));
|
||
f.write(mode.toUtf8());
|
||
}
|
||
|
||
QString TestRefOptimizer::activerTraces()
|
||
{
|
||
const QString d = m_dir.path() + QStringLiteral("/traces");
|
||
QDir().mkpath(d);
|
||
m_arb->setOptimizerTraceDirectory(d);
|
||
const QVariantMap r = rpc("SetOptimizerTrace", {{"enabled", true}});
|
||
[&] { QCOMPARE(r.value("energyError").toString(), QString("EnergyErrorNoError")); }();
|
||
return d;
|
||
}
|
||
|
||
QByteArray TestRefOptimizer::toutesLesTraces(const QString &dossier) const
|
||
{
|
||
QByteArray tout;
|
||
for (const QString &nom : QDir(dossier).entryList({"etm-powersync-optimizer-trace-*.ndjson"}, QDir::Files, QDir::Name)) {
|
||
QFile f(QDir(dossier).filePath(nom));
|
||
if (f.open(QIODevice::ReadOnly))
|
||
tout += f.readAll();
|
||
}
|
||
return tout;
|
||
}
|
||
|
||
QList<QVariantMap> TestRefOptimizer::traces(const QString &dossier)
|
||
{
|
||
QList<QVariantMap> l;
|
||
for (const QString &nom : QDir(dossier).entryList({"etm-powersync-optimizer-trace-*.ndjson"}, QDir::Files, QDir::Name)) {
|
||
QFile f(QDir(dossier).filePath(nom));
|
||
if (!f.open(QIODevice::ReadOnly))
|
||
continue;
|
||
for (const QByteArray &ligne : f.readAll().split('\n'))
|
||
if (!ligne.trimmed().isEmpty())
|
||
l << QJsonDocument::fromJson(ligne).toVariant().toMap();
|
||
}
|
||
return l;
|
||
}
|
||
|
||
// --- Tests -------------------------------------------------------------------------------------
|
||
|
||
void TestRefOptimizer::testTheCatalogueMatchesTheEngine()
|
||
{
|
||
// L'optimiseur de référence porte sa copie des codes émissibles et de leurs clés : elle doit
|
||
// être celle du moteur, sinon il émettrait des codes ou des clés que le moteur supprime.
|
||
QProcess p;
|
||
p.start("python3", {"-I", "-B", RefOptimizer::script(), "--list-codes"});
|
||
QVERIFY2(p.waitForFinished(15000), "--list-codes ne rend pas la main");
|
||
QCOMPARE(p.exitCode(), 0);
|
||
const QVariantMap ref = QJsonDocument::fromJson(p.readAllStandardOutput()).toVariant().toMap();
|
||
QVariantMap moteur;
|
||
for (const QString &code : DecisionCode::optimizerEmittableCodes())
|
||
moteur.insert(code, DecisionCode::optimizerParamKeys(code));
|
||
QCOMPARE(cleEtat(ref), cleEtat(moteur));
|
||
for (const QString &code : ref.keys())
|
||
QVERIFY(DecisionCode::knownCodes().contains(code));
|
||
}
|
||
|
||
void TestRefOptimizer::testAPythonThatDoesNotStartFailsClearly()
|
||
{
|
||
// Un sous-processus qui ne démarre pas fait échouer le test EN CLAIR et vite — jamais bloqué.
|
||
RefOptimizer ref;
|
||
QString erreur;
|
||
QElapsedTimer t;
|
||
t.start();
|
||
QVERIFY(!ref.demarrer("unix:///repertoire/absent/optimizer.sock", {}, &erreur));
|
||
QVERIFY2(erreur.contains("s'est arrêté au démarrage"), qUtf8Printable(erreur));
|
||
QVERIFY2(erreur.contains("FileNotFoundError") || erreur.contains("No such file"), qUtf8Printable(erreur));
|
||
QVERIFY(!ref.demarrer("tcp://127.0.0.1:0", {}, &erreur, "python3-absent-du-systeme"));
|
||
QVERIFY2(erreur.contains("ne démarre pas"), qUtf8Printable(erreur));
|
||
// Un processus qui démarre mais n'écoute jamais : borné par le délai, puis tué.
|
||
QVERIFY(!ref.demarrer("tcp://127.0.0.1:0", {"--mode", "mode-inconnu"}, &erreur));
|
||
QVERIFY2(erreur.contains("invalid choice"), qUtf8Printable(erreur));
|
||
QVERIFY2(t.elapsed() < 20000, qPrintable(QString::number(t.elapsed())));
|
||
}
|
||
|
||
void TestRefOptimizer::testNormalReproducesTheRules_data()
|
||
{
|
||
QTest::addColumn<int>("transport");
|
||
QTest::newRow("unix") << int(Transport::Unix);
|
||
QTest::newRow("tcp") << int(Transport::Tcp);
|
||
}
|
||
|
||
void TestRefOptimizer::testNormalReproducesTheRules()
|
||
{
|
||
// Décision Q6 — identité sur le banc RESTREINT, avec UN CYCLE DE DÉCALAGE (modèle cache) :
|
||
// ce que les mocks ont reçu au cycle i sous l'optimiseur == ce qu'ils ont reçu au cycle i − 1
|
||
// sous le rule-based seul, sur la même suite de surplus. Comparé : la consigne en watts des
|
||
// deux charges variables, l'état SG-Ready des contacts de la PAC, et qui a commandé. Non
|
||
// comparé : les motifs (l'optimiseur parle sous son niveau `optimizer`, Q3) et la comptabilité
|
||
// publiée (recalculée depuis l'appliqué, Q4).
|
||
QFETCH(int, transport);
|
||
monter(Banc::Restreint);
|
||
if (QTest::currentTestFailed())
|
||
return;
|
||
// Paliers de 4 cycles : import, demi-charge, plancher non atteint, état 3, état 4, la zone
|
||
// morte de l'hystérésis (reste en 4), retour, et import.
|
||
QList<double> surplus;
|
||
for (double s : {-1000.0, 1500.0, 2600.0, 4200.0, 6600.0, 9000.0, 8200.0, 3000.0, -500.0})
|
||
for (int k = 0; k < 4; ++k)
|
||
surplus << s;
|
||
const QDateTime tA = utcDateTime(QDate(2026, 6, 8), QTime(9, 0, 0));
|
||
const QDateTime tB = tA.addSecs(3 * 3600);
|
||
|
||
// Rule-based seul (aucun endpoint).
|
||
QList<QVariantMap> a;
|
||
for (int i = 0; i < surplus.count(); ++i) {
|
||
m_surplusW = surplus.at(i);
|
||
cycle(tA.addSecs(30 * i));
|
||
QVERIFY(!m_arb->socketScheduler());
|
||
a << etatApplique();
|
||
}
|
||
// Le banc revient à l'état de départ (tout à 0, PAC en 2) avant de brancher l'optimiseur.
|
||
m_surplusW = -1000;
|
||
for (int k = 0; k < 3; ++k)
|
||
cycle(tB.addSecs(-120 + 30 * k));
|
||
QCOMPARE(cleEtat(etatApplique()), cleEtat(a.first()));
|
||
|
||
RefOptimizer ref;
|
||
brancher(ref, Transport(transport), {});
|
||
if (QTest::currentTestFailed())
|
||
return;
|
||
QList<QVariantMap> b;
|
||
QList<bool> optimiseur;
|
||
for (int i = 0; i < surplus.count(); ++i) {
|
||
m_surplusW = surplus.at(i);
|
||
cycle(tB.addSecs(30 * i));
|
||
b << etatApplique();
|
||
optimiseur << m_arb->socketScheduler()->optimiseurAuDernierCycle();
|
||
}
|
||
|
||
// Cycle 0 : aucun plan en cache, le rule-based décide — sur le même contexte que A[0].
|
||
QVERIFY(!optimiseur.first());
|
||
QCOMPARE(cleEtat(b.first()), cleEtat(a.first()));
|
||
QStringList ecarts;
|
||
for (int i = 1; i < surplus.count(); ++i) {
|
||
if (!optimiseur.at(i))
|
||
ecarts << QStringLiteral("cycle %1 : le rule-based a commandé").arg(i);
|
||
if (cleEtat(b.at(i)) != cleEtat(a.at(i - 1)))
|
||
ecarts << QStringLiteral("cycle %1 (surplus %2) : optimiseur %3, rule-based au cycle %4 %5")
|
||
.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')));
|
||
// 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.
|
||
for (int i = 3; i < surplus.count(); i += 4) {
|
||
QCOMPARE(cleEtat(a.at(i)), cleEtat(a.at(i - 1)));
|
||
QCOMPARE(cleEtat(b.at(i)), cleEtat(a.at(i)));
|
||
}
|
||
// La suite a bien traversé les décisions qu'elle prétend éprouver.
|
||
QSet<QString> vus;
|
||
for (const QVariantMap &e : std::as_const(a))
|
||
vus.insert(cleEtat(e));
|
||
QVERIFY2(vus.count() >= 7, qPrintable(QString::number(vus.count())));
|
||
QCOMPARE(a.at(23).value("pac").toInt(), 4); // 9000 W → forcé
|
||
QCOMPARE(a.at(25).value("pac").toInt(), 4); // 8200 W : zone morte, reste en 4
|
||
QCOMPARE(a.at(11).value("var2").toDouble(), 0.0); // 600 W sous le plancher de 800 W
|
||
qInfo().noquote() << "[identité]" << surplus.count() << "cycles, update() max" << m_updateMaxMs << "ms";
|
||
}
|
||
|
||
void TestRefOptimizer::testEachFaultModeGivesItsRow_data()
|
||
{
|
||
// Chaque ligne : le mode, s'il faut redémarrer l'optimiseur (handshake), puis la ligne
|
||
// « stratégie|vivant|échecs|dernier code » après chacun des deux cycles en défaut et des deux
|
||
// cycles revenus en `normal`, et les notifications OptimizerStatusChanged, une par transition.
|
||
QTest::addColumn<QString>("mode");
|
||
QTest::addColumn<bool>("redemarrer");
|
||
QTest::addColumn<QStringList>("lignes");
|
||
QTest::addColumn<QStringList>("notifications");
|
||
|
||
const QStringList abstention{"optimizer|vivant|0|", "rules|vivant|0|", "rules|vivant|0|", "optimizer|vivant|0|"};
|
||
const QStringList unePause{"attendu|vivant|rules", "attendu|vivant|optimizer"};
|
||
auto invalide = [](const QString &code) {
|
||
return QStringList{"optimizer|vivant|1|" + code, "rules|mort|2|" + code, "rules|vivant|0|" + code,
|
||
"optimizer|vivant|0|" + code};
|
||
};
|
||
const QStringList mortPuisVivant{"attendu|vivant|rules", "attendu|mort|rules", "attendu|vivant|rules",
|
||
"attendu|vivant|optimizer"};
|
||
QTest::newRow("abstain") << "abstain" << false << abstention << unePause;
|
||
QTest::newRow("expired") << "expired" << false << abstention << unePause;
|
||
QTest::newRow("invalid") << "invalid" << false << invalide("malformed-plan") << mortPuisVivant;
|
||
QTest::newRow("unknown-load") << "unknown-load" << false << invalide("unknown-load") << mortPuisVivant;
|
||
QTest::newRow("forbidden-kind") << "forbidden-kind" << false << invalide("kind-mismatch") << mortPuisVivant;
|
||
QTest::newRow("missing-load") << "missing-load" << false << invalide("incomplete-slot") << mortPuisVivant;
|
||
// slow (12 s > 10 s) : le premier délai dépassé garde le plan en cache, le second tue.
|
||
QTest::newRow("slow") << "slow" << false
|
||
<< QStringList{"optimizer|vivant|1|timeout", "rules|mort|2|timeout",
|
||
"rules|vivant|0|timeout", "optimizer|vivant|0|timeout"}
|
||
<< QStringList{"attendu|mort|rules", "attendu|vivant|rules", "attendu|vivant|optimizer"};
|
||
// drop : la connexion fermée tue tout de suite (§7 « socket fermé »), l'échec de la requête
|
||
// en vol est compté (`disconnected`). La reconnexion qui suit n'est pas une nouvelle perte :
|
||
// le dernier code reste celui de l'incident.
|
||
QTest::newRow("drop") << "drop" << false
|
||
<< QStringList{"rules|mort|1|disconnected", "rules|mort|2|disconnected",
|
||
"rules|vivant|0|disconnected", "optimizer|vivant|0|disconnected"}
|
||
<< QStringList{"attendu|mort|rules", "attendu|vivant|rules", "attendu|vivant|optimizer"};
|
||
// wrong-major : au handshake suivant (optimiseur redémarré) ; aucune requête ne part.
|
||
QTest::newRow("wrong-major") << "wrong-major" << true
|
||
<< QStringList{"rules|mort|0|major-mismatch", "rules|mort|0|major-mismatch",
|
||
"rules|vivant|0|major-mismatch", "optimizer|vivant|0|major-mismatch"}
|
||
<< QStringList{"attendu|mort|rules", "attendu|vivant|rules", "attendu|vivant|optimizer"};
|
||
}
|
||
|
||
void TestRefOptimizer::testEachFaultModeGivesItsRow()
|
||
{
|
||
QFETCH(QString, mode);
|
||
QFETCH(bool, redemarrer);
|
||
QFETCH(QStringList, lignes);
|
||
QFETCH(QStringList, notifications);
|
||
monter(Banc::Complet);
|
||
if (QTest::currentTestFailed())
|
||
return;
|
||
ecrireMode("normal");
|
||
RefOptimizer ref;
|
||
const Transport transport = mode == "drop" || mode == "wrong-major" ? Transport::Tcp : Transport::Unix;
|
||
brancher(ref, transport, {"--mode-file", m_fichierMode});
|
||
if (QTest::currentTestFailed())
|
||
return;
|
||
SocketScheduler *s = m_arb->socketScheduler();
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(9, 0, 0));
|
||
m_surplusW = 6600;
|
||
cycle(t0);
|
||
cycle(t0.addSecs(30));
|
||
QCOMPARE(ligneStatut(), QString("optimizer|vivant|0|"));
|
||
QCOMPARE(triplet(), QString("attendu|vivant|optimizer"));
|
||
const QString endpoint = ref.endpoint();
|
||
|
||
QSignalSpy spy(m_arb, &EnergyArbitrator::optimizerStatusChanged);
|
||
QStringList obtenu;
|
||
ecrireMode(mode);
|
||
if (redemarrer) {
|
||
ref.arreter();
|
||
QString erreur;
|
||
QVERIFY2(ref.demarrer(endpoint, {"--mode-file", m_fichierMode}, &erreur), qUtf8Printable(erreur));
|
||
QVERIFY2(waitFor([s] { return s->client()->lastErrorCode() == QLatin1String("major-mismatch"); }, 10000),
|
||
qUtf8Printable(s->client()->lastErrorCode()));
|
||
}
|
||
m_updateMaxMs = 0;
|
||
for (int k = 0; k < 4; ++k) {
|
||
if (k == 2) {
|
||
ecrireMode("normal");
|
||
if (redemarrer) {
|
||
ref.arreter();
|
||
QString erreur;
|
||
QVERIFY2(ref.demarrer(endpoint, {"--mode-file", m_fichierMode}, &erreur), qUtf8Printable(erreur));
|
||
}
|
||
}
|
||
// Après une fermeture, le client se reconnecte au backoff (1 s, 2 s…) : on l'attend.
|
||
if (k >= 1 && (mode == "drop" || (redemarrer && k >= 2)))
|
||
QVERIFY2(waitFor([s] { return s->client()->state() == OptimizerClient::Ready; }, 15000),
|
||
qUtf8Printable(s->client()->lastErrorCode()));
|
||
const QDateTime t = t0.addSecs(60 + 30 * k);
|
||
if (mode == "slow" && k < 2) {
|
||
const int echecs = m_codesEchec.count();
|
||
cycle(t, false);
|
||
QVERIFY2(waitFor([&] { return m_codesEchec.count() > echecs; }, 15000), "aucun délai dépassé");
|
||
QCOMPARE(m_codesEchec.last(), QString("timeout"));
|
||
} else {
|
||
cycle(t);
|
||
}
|
||
obtenu << ligneStatut();
|
||
}
|
||
QCOMPARE(obtenu, lignes);
|
||
QCOMPARE(triplets(spy), notifications);
|
||
// Le plan publié dit qu'aucun plan d'optimiseur ne commande, et pourquoi — au cycle repli.
|
||
if (mode == "slow")
|
||
QVERIFY2(m_updateMaxMs < 250, qPrintable(QStringLiteral("update() a duré %1 ms").arg(m_updateMaxMs)));
|
||
qInfo().noquote() << "[mode]" << mode << obtenu.join(" ; ") << "— update() max" << m_updateMaxMs << "ms";
|
||
}
|
||
|
||
void TestRefOptimizer::testAProtocol10OptimizerIsAccepted()
|
||
{
|
||
// Un optimiseur 1.0 — handshake sans capabilities, plan sans validUntil, sans code ni
|
||
// paramètres de motif, sans diagnostics — reste accepté (§9, champs 1.1 optionnels).
|
||
monter(Banc::Restreint);
|
||
if (QTest::currentTestFailed())
|
||
return;
|
||
RefOptimizer ref;
|
||
brancher(ref, Transport::Unix, {"--mode", "protocol-1.0"});
|
||
if (QTest::currentTestFailed())
|
||
return;
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(9, 0, 0));
|
||
m_surplusW = 6600;
|
||
for (int k = 0; k < 3; ++k)
|
||
cycle(t0.addSecs(30 * k));
|
||
QVERIFY(m_arb->socketScheduler()->optimiseurAuDernierCycle());
|
||
const QVariantMap st = rpc("GetOptimizerStatus");
|
||
QCOMPARE(st.value("optimizerAlive").toBool(), true);
|
||
QCOMPARE(st.value("protocolVersion").toString(), QString("1.0"));
|
||
QCOMPARE(st.value("name").toString(), QString("powersync-optimizer"));
|
||
QVERIFY(!st.contains("capabilities"));
|
||
QCOMPARE(cleEtat(etatApplique()), cleEtat(QVariantMap{{"var1", 2000.0}, {"var2", 3000.0}, {"pac", 3}}));
|
||
// Sans reasonCode, le motif vers l'app est le code générique, sans paramètres.
|
||
for (const QVariant &v : m_arb->loadTelemetry().value("loads").toList())
|
||
QCOMPARE(v.toMap().value("decision").toMap().value("code").toString(), QString(DecisionCode::OptimizerDecision));
|
||
const QVariantMap plan = rpc("GetPlan");
|
||
QCOMPARE(plan.value("strategy").toString(), QString("optimizer"));
|
||
}
|
||
|
||
void TestRefOptimizer::testTracesReplayWithZeroDivergence()
|
||
{
|
||
// Traces de bout en bout : des cycles sous le rule-based seul, puis sous l'optimiseur de
|
||
// référence ; les traces sont rejouées par tools/etm-powersync-trace-replay sur un NOUVEL
|
||
// optimiseur de référence → zéro divergence (les lignes du rule-based comprises : sur le banc
|
||
// restreint, la référence décide comme lui, et sans décalage puisque chaque requête est
|
||
// rejouée sur son propre contexte). Puis sur un optimiseur qui dévie → divergence, code 1.
|
||
monter(Banc::Restreint);
|
||
if (QTest::currentTestFailed())
|
||
return;
|
||
const QString d = activerTraces();
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(9, 0, 0));
|
||
const QList<double> surplus{1500, 2600, 4200, 6600, 9000, 8200, 3000, -500};
|
||
int i = 0;
|
||
for (double s : surplus) {
|
||
m_surplusW = s;
|
||
cycle(t0.addSecs(30 * i++));
|
||
}
|
||
{
|
||
RefOptimizer ref;
|
||
brancher(ref, Transport::Unix, {});
|
||
if (QTest::currentTestFailed())
|
||
return;
|
||
for (double s : surplus) {
|
||
m_surplusW = s;
|
||
cycle(t0.addSecs(30 * i++));
|
||
}
|
||
QVERIFY(m_arb->socketScheduler()->optimiseurAuDernierCycle());
|
||
QCOMPARE(rpc("SetOptimizerTrace", {{"enabled", false}}).value("energyError").toString(),
|
||
QString("EnergyErrorNoError")); // la ligne en attente est terminée et écrite
|
||
}
|
||
const QList<QVariantMap> lignes = traces(d);
|
||
QCOMPARE(lignes.count(), 2 * surplus.count());
|
||
int regles = 0, optimiseur = 0;
|
||
for (const QVariantMap &l : lignes) {
|
||
regles += l.value("strategy").toString() == "rules";
|
||
optimiseur += l.value("strategy").toString() == "optimizer";
|
||
}
|
||
QCOMPARE(regles, surplus.count() + 1); // le premier cycle sous endpoint : pas de plan en cache
|
||
QCOMPARE(optimiseur, surplus.count() - 1);
|
||
const QStringList fichiers = QDir(d).entryList({"etm-powersync-optimizer-trace-*.ndjson"}, QDir::Files);
|
||
QCOMPARE(fichiers.count(), 1);
|
||
const QString trace = QDir(d).filePath(fichiers.first());
|
||
|
||
RefOptimizer neuf;
|
||
QString erreur;
|
||
QVERIFY2(neuf.demarrer(QStringLiteral("unix://%1/rejeu.sock").arg(m_dir.path()), {}, &erreur), qUtf8Printable(erreur));
|
||
QString sortie;
|
||
int code = lancerRejeu({trace, "--endpoint", neuf.endpoint(), "--fail-on-divergence"}, &sortie);
|
||
QVERIFY2(code == 0, qUtf8Printable(QStringLiteral("code %1\n%2").arg(code).arg(sortie)));
|
||
QVERIFY2(sortie.contains(QStringLiteral("requêtes rejouées : %1").arg(lignes.count())), qUtf8Printable(sortie));
|
||
QVERIFY2(sortie.contains("cycles dont les actions diffèrent du plan enregistré : 0"), qUtf8Printable(sortie));
|
||
QVERIFY2(sortie.contains("cycles non comparables (réponse enregistrée en erreur, ou rejouée en erreur) : 0"),
|
||
qUtf8Printable(sortie));
|
||
neuf.arreter();
|
||
qInfo().noquote() << "[rejeu normal]" << sortie;
|
||
|
||
RefOptimizer devie;
|
||
QVERIFY2(devie.demarrer(QStringLiteral("unix://%1/devie.sock").arg(m_dir.path()), {"--mode", "deviate"}, &erreur),
|
||
qUtf8Printable(erreur));
|
||
code = lancerRejeu({trace, "--endpoint", devie.endpoint(), "--fail-on-divergence"}, &sortie);
|
||
QVERIFY2(code == 1, qUtf8Printable(QStringLiteral("code %1\n%2").arg(code).arg(sortie)));
|
||
QVERIFY2(!sortie.contains("cycles dont les actions diffèrent du plan enregistré : 0\n"), qUtf8Printable(sortie));
|
||
qInfo().noquote() << "[rejeu dévié]" << sortie;
|
||
}
|
||
|
||
void TestRefOptimizer::testSafetyWinsOverTheOptimizer()
|
||
{
|
||
// L4 et L2 pendant que l'optimiseur de référence commande : la sécurité gagne, et `executed`
|
||
// (trace) le montre.
|
||
monter(Banc::Restreint);
|
||
if (QTest::currentTestFailed())
|
||
return;
|
||
m_arb->setPhasePowerLimit(25);
|
||
const QString d = activerTraces();
|
||
RefOptimizer ref;
|
||
brancher(ref, Transport::Unix, {});
|
||
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));
|
||
QVERIFY(m_arb->socketScheduler()->optimiseurAuDernierCycle());
|
||
const QVariantMap avant = etatApplique();
|
||
QCOMPARE(cleEtat(avant), cleEtat(QVariantMap{{"var1", 2000.0}, {"var2", 3000.0}, {"pac", 4}}));
|
||
|
||
// L4 — surcharge sur la phase A : l'optimiseur commande toujours le même plan, L4 déleste.
|
||
m_compteur->setStateValue("currentPhaseA", 32);
|
||
m_surplusW = -2000; // la maison importe : le plan en cache, lui, ne le sait pas encore
|
||
cycle(t0.addSecs(90));
|
||
QVERIFY(m_arb->socketScheduler()->optimiseurAuDernierCycle());
|
||
const QVariantMap apres = etatApplique();
|
||
QVERIFY2(apres.value("var1").toDouble() + apres.value("var2").toDouble()
|
||
< avant.value("var1").toDouble() + avant.value("var2").toDouble()
|
||
|| apres.value("pac").toInt() < avant.value("pac").toInt(),
|
||
qUtf8Printable(cleEtat(apres)));
|
||
QList<QVariantMap> l = traces(d);
|
||
QVERIFY(waitFor([&] { l = traces(d); return l.count() >= 4; }, 5000));
|
||
const QVariantMap ligneL4 = l.at(3);
|
||
QCOMPARE(ligneL4.value("strategy").toString(), QString("optimizer"));
|
||
bool delestee = false;
|
||
for (const QVariant &e : ligneL4.value("executed").toList())
|
||
delestee |= e.toMap().value("shed").toBool();
|
||
QVERIFY2(delestee, qUtf8Printable(QJsonDocument::fromVariant(ligneL4.value("executed")).toJson()));
|
||
|
||
// L2 — compteur muet > 90 s : plus aucune stratégie, consignes de repli, même avec un
|
||
// optimiseur vivant et un plan en cache qui commande TOUT à fond.
|
||
m_compteur->setStateValue("currentPhaseA", 0);
|
||
m_surplusW = 9000;
|
||
for (int k = 0; k < 3; ++k)
|
||
cycle(t0.addSecs(120 + 30 * k));
|
||
QVERIFY(m_arb->socketScheduler()->optimiseurAuDernierCycle());
|
||
QCOMPARE(cleEtat(etatApplique()), cleEtat(avant));
|
||
m_arb->evaluateMeterFreshness(t0.addSecs(300));
|
||
QVERIFY(m_arb->degradedMode());
|
||
m_arb->simulationCallUpdate(t0.addSecs(330));
|
||
settle(200);
|
||
QCOMPARE(rpc("GetOptimizerStatus").value("activeStrategy").toString(), QString("none"));
|
||
QCOMPARE(cleEtat(etatApplique()), cleEtat(QVariantMap{{"var1", 0.0}, {"var2", 0.0}, {"pac", 2}}));
|
||
QVERIFY(waitFor([&] { l = traces(d); return !l.isEmpty() && l.last().value("strategy").toString() == "none"; }, 5000));
|
||
QCOMPARE(l.last().value("executed").toList().count(), 0);
|
||
QVERIFY(!l.last().contains("request"));
|
||
|
||
// Le compteur reparle : l'optimiseur reprend la main.
|
||
m_surplusW = 9000;
|
||
for (int k = 0; k < 3; ++k)
|
||
cycle(t0.addSecs(360 + 30 * k));
|
||
QVERIFY(!m_arb->degradedMode());
|
||
QVERIFY(m_arb->socketScheduler()->optimiseurAuDernierCycle());
|
||
QCOMPARE(triplet(), QString("attendu|vivant|optimizer"));
|
||
}
|
||
|
||
void TestRefOptimizer::testAnEchoedTokenLeaksNowhereEndToEnd()
|
||
{
|
||
// Correctif A, de bout en bout : l'optimiseur de référence, en `echo-token`, recopie le token
|
||
// (généré ici, avec " et \) dans `reason` et `planId`. Le secret ne sort par aucune ligne de
|
||
// journal (hors la trace brute ENTRANTE de nymea, qui porte la pose du token), aucune trace,
|
||
// aucune réponse ni notification RPC.
|
||
monter(Banc::Restreint);
|
||
if (QTest::currentTestFailed())
|
||
return;
|
||
const QString secret = QStringLiteral("T7-%1\"bout\\en-bout").arg(QRandomGenerator::global()->generate64(), 0, 16);
|
||
QCOMPARE(rpc("SetOptimizerToken", {{"token", secret}}).value("energyError").toString(), QString("EnergyErrorNoError"));
|
||
const QString fichierToken = m_dir.path() + "/token";
|
||
{
|
||
QFile f(fichierToken);
|
||
QVERIFY(f.open(QIODevice::WriteOnly));
|
||
QVERIFY(f.setPermissions(QFileDevice::ReadOwner | QFileDevice::WriteOwner));
|
||
f.write(secret.toUtf8());
|
||
}
|
||
const QString d = activerTraces();
|
||
QSignalSpy paquets(m_mockTcpServer, &MockTcpServer::outgoingData);
|
||
QLoggingCategory::setFilterRules(QStringLiteral("*.debug=true"));
|
||
Journal j;
|
||
RefOptimizer ref;
|
||
brancher(ref, Transport::Tcp, {"--mode", "echo-token", "--token-file", fichierToken});
|
||
if (QTest::currentTestFailed())
|
||
return;
|
||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(9, 0, 0));
|
||
m_surplusW = 6600;
|
||
for (int k = 0; k < 3; ++k)
|
||
cycle(t0.addSecs(30 * k));
|
||
QVERIFY(m_arb->socketScheduler()->optimiseurAuDernierCycle()); // le token était le bon
|
||
rpc("GetOptimizerStatus");
|
||
rpc("GetPlan");
|
||
injectAndWait("NymeaEnergy.GetLoadTelemetry");
|
||
QCOMPARE(rpc("SetOptimizerTrace", {{"enabled", false}}).value("energyError").toString(), QString("EnergyErrorNoError"));
|
||
|
||
const QByteArray echappe = QJsonDocument(QJsonArray{secret}).toJson(QJsonDocument::Compact).mid(2).chopped(2);
|
||
const QStringList fragments{secret, QString::fromUtf8(echappe), secret.section('"', 0, 0)};
|
||
QVERIFY2(j.texte().contains("[token masqué]"), "l'écho n'a pas été vu : le test ne prouverait rien");
|
||
QCOMPARE(j.compte("recopie le token"), 1);
|
||
for (const QString &f : fragments) {
|
||
QStringList fuites;
|
||
for (const QString &x : j.lignes(f))
|
||
if (!x.startsWith(QLatin1String("JsonRpcTraffic: ")))
|
||
fuites << x.left(300);
|
||
QVERIFY2(fuites.isEmpty(), qUtf8Printable(fuites.join('\n')));
|
||
}
|
||
QVERIFY(paquets.count() >= 3);
|
||
for (const QList<QVariant> &p : paquets)
|
||
for (const QString &f : fragments)
|
||
QVERIFY2(!p.last().toByteArray().contains(f.toUtf8()), p.last().toByteArray().left(300).constData());
|
||
const QByteArray tout = toutesLesTraces(d);
|
||
QVERIFY(tout.contains("[token masqué]"));
|
||
for (const QString &f : fragments)
|
||
QVERIFY2(!tout.contains(f.toUtf8()), qUtf8Printable(f));
|
||
|
||
QFile::remove(OptimizerClient::defaultTokenPath());
|
||
QLoggingCategory::setFilterRules(QStringLiteral(
|
||
"*.debug=false\nApplication.debug=true\nSimulation.debug=true\nExperiences.debug=true\n"
|
||
"NymeaEnergy.debug=true\nEnergyMocks.debug=true\nDBus.warning=false"));
|
||
}
|
||
|
||
QTEST_MAIN(TestRefOptimizer)
|
||
#include "testrefoptimizer.moc"
|