diff --git a/tests/auto/auto.pro b/tests/auto/auto.pro index 4cf9efd..6d2061f 100644 --- a/tests/auto/auto.pro +++ b/tests/auto/auto.pro @@ -1,2 +1,2 @@ TEMPLATE = subdirs -SUBDIRS += charging decisions loadmodel optimizerclient simulation socketscheduler spotmarket +SUBDIRS += charging decisions loadmodel optimizerclient refoptimizer simulation socketscheduler spotmarket diff --git a/tests/auto/refoptimizer/refoptimizer.pro b/tests/auto/refoptimizer/refoptimizer.pro new file mode 100644 index 0000000..1fd1d6a --- /dev/null +++ b/tests/auto/refoptimizer/refoptimizer.pro @@ -0,0 +1,43 @@ +# Brief 4 T7 — tests d'intégration de fin de lot (§3) : l'arbitre RÉEL, le serveur nymea et les +# mocks de la suite `simulation`, et l'optimiseur de référence RÉEL +# (tools/etm-powersync-ref-optimizer), lancé en sous-processus Python par chaque test. +# +# Suite À PART, comme `socketscheduler` : elle ne touche pas la référence `decisions`. + +include(../common/common.pri) +include(../../../energyplugin/energyplugin.pri) +include(../../mocks/spotmarketprovider/spotmarketdataprovidermock.pri) + +CONFIG += testcase + +TARGET = etm-powersync-refoptimizer + +DEFINES += ENERGY_SIMULATION +# Racine des sources : le test y trouve l'optimiseur de référence et l'outil de rejeu. Surchargée +# à l'exécution par $ETM_POWERSYNC_SOURCE_DIR (arbre mutant, arbre archivé). +DEFINES += ETM_POWERSYNC_SOURCE_DIR=\\\"$$clean_path($$PWD/../../..)\\\" + +SIM = $$PWD/../simulation +INCLUDEPATH += $$SIM + +RESOURCES += \ + $$SIM/simulationresources.qrc + +HEADERS += \ + $$SIM/experience/energyexperienceenergymock.h \ + $$SIM/experience/energyjsonhandler.h \ + $$SIM/experience/energylogger.h \ + $$SIM/experience/energymanagermock.h \ + $$SIM/simulationbase.h \ + $$PWD/../common/etm-powersync-fakeoptimizer.h + +SOURCES += \ + $$SIM/experience/energyexperienceenergymock.cpp \ + $$SIM/experience/energyjsonhandler.cpp \ + $$SIM/experience/energylogger.cpp \ + $$SIM/experience/energymanagermock.cpp \ + $$SIM/simulationbase.cpp \ + testrefoptimizer.cpp + +target.path = $$[QT_INSTALL_PREFIX]/bin +INSTALLS += target diff --git a/tests/auto/refoptimizer/testrefoptimizer.cpp b/tests/auto/refoptimizer/testrefoptimizer.cpp new file mode 100644 index 0000000..b82e914 --- /dev/null +++ b/tests/auto/refoptimizer/testrefoptimizer.cpp @@ -0,0 +1,946 @@ +// 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 +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +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 » 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 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(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{{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 &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 TestRefOptimizer::traces(const QString &dossier) +{ + QList 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("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 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 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 b; + QList 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'))); + // 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 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("mode"); + QTest::addColumn("redemarrer"); + QTest::addColumn("lignes"); + QTest::addColumn("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`). Lu juste après le cycle, avant la reconnexion (1 s) ; + // la TENTATIVE de reconnexion réécrit ensuite le dernier code avec celui du client + // (`connection-lost`) — constaté ici, décrit au rapport T7. + QTest::newRow("drop") << "drop" << false + << QStringList{"rules|mort|1|disconnected", "rules|mort|2|disconnected", + "rules|vivant|0|connection-lost", "optimizer|vivant|0|connection-lost"} + << 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")); + 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 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 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 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. + m_compteur->setStateValue("currentPhaseA", 0); + 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 &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"