Patrick Schurig 10683744d2 test(traces): le traceur n'apparie une réponse qu'à la ligne de sa requête (brief 4 T6)
Contre-épreuve T4 : sans le contrôle d'identifiant du traceur, aucun test ne cassait — le
SocketScheduler écarte déjà les réponses obsolètes en amont. Ce test unitaire éprouve la
seconde garde seule (autre requête, aucune requête, réponse en double). socketscheduler :
34 fonctions, 117 / 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 12:24:02 +02:00

1949 lines
100 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
//
// Brief 4 T3 — la stratégie optimiseur dans l'arbitre RÉEL : endpoint lu dans energy.conf, faux
// optimiseur du processus, cycles injectés (temps simulé pour l'arbitre ; seules les E/S de
// socket passent par la boucle d'événements). Chaque test monte son banc : un compteur, un ECS
// à un relais de 1000 W (rang 1), un routeur etmvariableload de 3000 W (rang 2).
#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/publishedplan.h"
#include "etm/scheduler/optimizertrace.h"
#include "etm/types/decisionreason.h"
#include <QFileInfo>
#include <QJsonArray>
#include <QJsonDocument>
#include <QLoggingCategory>
#include <QSignalSpy>
#include <QTemporaryDir>
#include <experiences/experiencemanager.h>
#include <nymeasettings.h>
#include <unistd.h>
using namespace nymeaserver;
namespace {
const QString kTexteLibre = QStringLiteral("Texte LIBRE de l'optimiseur, qui ne doit jamais sortir");
//! Capture du journal le temps d'une portée (même principe que la suite simulation).
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); }
//! Les lignes contenant \p fragment, préfixées de leur catégorie (« JsonRpcTraffic: … »).
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;
QHash<QString, QVariantMap> telemetrieParId(const QVariantMap &snap)
{
QHash<QString, QVariantMap> h;
for (const QVariant &v : snap.value("loads").toList())
h.insert(v.toMap().value("loadId").toString(), v.toMap());
return h;
}
QVariantMap action(const QString &id, const QVariantMap &champs)
{
QVariantMap a{{"loadId", id}, {"kind", "setpoint"}, {"reason", kTexteLibre}};
for (auto it = champs.constBegin(); it != champs.constEnd(); ++it)
a.insert(it.key(), it.value());
return a;
}
} // namespace
class TestSocketScheduler : public SimulationBase
{
Q_OBJECT
private slots:
void testNoEndpointNoObject();
void testValidPlanIsAppliedOnTheNextCycle_data();
void testValidPlanIsAppliedOnTheNextCycle();
void testRefusedPlanFallsBackToRules_data();
void testRefusedPlanFallsBackToRules();
void testOutOfBoundsActionIsClamped();
void testUnavailableLoadKeepsTheShadowAction();
void testReasonIsFilteredOnTheWayToTheApp();
void testL4ShedsALoadTheOptimizerCommands();
void testDegradedModeSuspendsPlanning();
void testBudgetAndDrawAreRecomputedFromTheApplied();
void testTheTriggerNamesTheCause();
void testANewInvalidPlanReplacesTheCache();
void testConnectionLossDropsThePlan();
void testThePlanOfAnOptimizerBuysNothingEco();
void testPvIsReadFromAnyProducer();
// Brief 4 T4 — machine de repli §7, capacités, réglage par RPC, plan publié
void testFallbackMachineIsPure();
void testStatusWithoutEndpoint();
void testStatusFollowsTheFallbackTable_data();
void testStatusFollowsTheFallbackTable();
void testEachTransitionNotifiesOnce();
void testEndpointIsSetByRpcAndAppliedHot();
void testTokenIsWriteOnlyAndNeverLeaks();
void testGetPlanPublishesThePlanInForce();
void testGetPlanShowsTheAppliedPowerUnderALock();
void testUpdateLearnedIsLoggedOnceAndIgnored();
// Brief 4 T6 — traces de décision (annexe D)
void testTraceIsOffByDefault();
void testTraceSettingByRpc();
void testTraceLineUnderRulesAndInL2();
void testTraceLineUnderTheOptimizer();
void testTraceAbstentionFailureAndNoResponse();
void testTraceRotatesAtLocalMidnightAndPurges();
void testTraceCapStopsUntilTheNextDay();
void testTraceWriteFailureChangesNoDecision();
void testTokenIsInNoTrace();
void testTraceLineSize();
void testTracerPairsTheResponseByRequestId();
private:
//! Banc commun ; \p avecOptimiseur faux = rien n'est configuré.
void monter(bool avecOptimiseur = true, FakeOptimizer::Transport transport = FakeOptimizer::Unix);
//! Un cycle à \p t, compteur à \p compteurW (négatif = export) ; attend le jugement de la
//! réponse si le faux optimiseur doit répondre.
void cycle(const QDateTime &t, double compteurW);
//! Plan d'un créneau couvrant [ts − 60 s, ts + 15 min[, valable 30 min, depuis la requête.
static QVariantMap plan(const QVariantMap &requete, const QVariantList &actions,
int validiteS = 1800);
QHash<QString, QVariantMap> telemetrie() const { return telemetrieParId(m_arb->loadTelemetry()); }
EnergyArbitrator *m_arb = nullptr;
Thing *m_compteur = nullptr;
QUuid m_relais;
QString m_var;
QTemporaryDir m_dir;
FakeOptimizer *m_serveur = nullptr;
QJsonObject m_handshake;
QList<QVariantMap> m_requetes;
//! Ce que le faux optimiseur répond à un GetPlan ; vide = il ne répond pas.
std::function<QVariantMap(const QVariantMap &)> m_repondre;
int m_jugees = 0;
QStringList m_codesJuges;
QStringList m_codesEchec; //!< echecRequete() reçus (T4)
QList<QJsonObject> m_handshakes; //!< params de chaque Handshake reçu (token)
//! Statut publié par l'arbitre, en trois mots : « attendu|vivant|stratégie ».
QString triplet() const;
//! Appel RPC ; rend `params` de la réponse.
QVariantMap rpc(const QString &methode, const QVariantMap &params = QVariantMap());
//! Plan valide sur les deux charges (ECS 1000 W, routeur 500 W), valable \p validiteS.
std::function<QVariantMap(const QVariantMap &)> planValide(int validiteS = 1800);
//! Optimiseur monté, plan valide reçu puis appliqué : « attendu|vivant|optimizer » à t0 + 60 s.
void amenerAOptimiseur(const QDateTime &t0, int validiteS = 1800);
//! Triplets publiés par les notifications \c OptimizerStatusChanged de l'arbitre.
static QStringList triplets(const QSignalSpy &spy);
//! T6 : active les traces dans un répertoire temporaire propre ; rend son chemin.
QString activerTraces();
//! T6 : les lignes de trace de \p dossier, tous fichiers, dans l'ordre ; attend d'en avoir
//! au moins \p n (l'écrivain est asynchrone).
static QList<QVariantMap> traces(const QString &dossier, int n = 0);
//! T6 : les fichiers de trace de \p dossier, triés.
static QStringList fichiersTrace(const QString &dossier);
//! T6 : copie les traces de \p dossier sous \c $ETM_POWERSYNC_TRACE_EXPORT (s'il est posé),
//! préfixées de \p nom — la matière du test de \c tools/etm-powersync-trace-replay.
static void exporterTraces(const QString &dossier, const QString &nom);
QTemporaryDir m_dossierTraces;
};
QString TestSocketScheduler::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());
}
QVariantMap TestSocketScheduler::rpc(const QString &methode, const QVariantMap &params)
{
const QVariantMap r = injectAndWait("NymeaEnergy." + methode, params).toMap();
[&] { QCOMPARE(r.value("status").toString(), QString("success")); }();
return r.value("params").toMap();
}
std::function<QVariantMap(const QVariantMap &)> TestSocketScheduler::planValide(int validiteS)
{
return [this, validiteS](const QVariantMap &req) {
return plan(req, {action("ecs", {{"powerW", 1000}}), action(m_var, {{"powerW", 500}})}, validiteS);
};
}
void TestSocketScheduler::amenerAOptimiseur(const QDateTime &t0, int validiteS)
{
m_repondre = planValide(validiteS);
cycle(t0, -3000);
cycle(t0.addSecs(60), -3000);
QVERIFY(m_arb->socketScheduler()->optimiseurAuDernierCycle());
QCOMPARE(triplet(), QString("attendu|vivant|optimizer"));
}
QStringList TestSocketScheduler::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;
}
void TestSocketScheduler::monter(bool avecOptimiseur, FakeOptimizer::Transport transport)
{
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-socketscheduler.json";
QFile::remove(cfgPath);
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
cleanupTestCase();
// T4 : chaque banc part sans endpoint ni token — l'arbitre les lit à sa construction, et une
// fonction précédente du même processus a pu les poser.
EnergySettings().remove("Optimizer/endpoint");
QFile::remove(OptimizerClient::defaultTokenPath());
// T6 : ni traces ni réglage de traces hérités d'une fonction précédente.
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);
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);
m_relais = addPowerSwitch(1000, 26661);
const QUuid varId = addEtmVariableLoad(27043);
m_var = varId.toString();
Thing *routeur = tm->findConfiguredThing(varId);
QVERIFY(tm->findConfiguredThing(m_relais) && routeur);
routeur->setStateValue("currentPowerW", 0);
QVariantList l;
l.append(QVariantMap{{"id", "ecs"}, {"label", "ECS"}, {"adapter", "relay-router"}, {"mode", "fixed"},
{"priority", 1}, {"enabled", true},
{"relays", QVariantList() << QVariantMap{{"thingId", m_relais.toString()}, {"powerW", 1000}}},
{"minOnS", 0}, {"minOffS", 0}});
l.append(QVariantMap{{"id", m_var}, {"label", "Routeur"}, {"adapter", "etmvariableload"},
{"mode", "dynamic"}, {"maxPowerW", 3000}, {"priority", 2}, {"enabled", true}});
QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", l}}))
.toMap().value("params").toMap().value("energyError").toString(),
QString("EnergyErrorNoError"));
m_requetes.clear();
m_repondre = nullptr;
m_jugees = 0;
m_codesJuges.clear();
m_codesEchec.clear();
m_handshakes.clear();
if (!avecOptimiseur)
return;
QVERIFY(m_dir.isValid());
delete m_serveur;
m_serveur = new FakeOptimizer(transport, m_dir.path());
QVERIFY(m_serveur->listen());
if (m_handshake.isEmpty())
m_handshake = handshakeResult();
m_serveur->responder = [this](const QJsonObject &m) {
const qint64 id = m.value("id").toInteger();
if (m.value("method").toString() == QLatin1String("Handshake")) {
m_handshakes.append(m.value("params").toObject());
m_serveur->replyResult(id, m_handshake);
return true;
}
if (m.value("method").toString() == QLatin1String("GetPlan")) {
const QVariantMap params = m.value("params").toObject().toVariantMap();
m_requetes.append(params);
if (m_repondre) {
const QVariantMap r = m_repondre(params);
if (r.contains("__erreur")) // T4 : une erreur JSON-RPC, échec de la requête
m_serveur->sendJson(QJsonObject{{"jsonrpc", "2.0"}, {"id", id},
{"error", QJsonObject{{"code", -32000}, {"message", "panne"}}}});
else if (!r.isEmpty())
m_serveur->replyResult(id, QJsonObject::fromVariantMap(r));
}
return true;
}
return false;
};
EnergySettings().setValue("Optimizer/endpoint", m_serveur->endpoint());
m_arb->relireReglageOptimiseur();
SocketScheduler *s = m_arb->socketScheduler();
QVERIFY(s);
connect(s, &SocketScheduler::reponseJugee, this, [this](const QString &, const QString &code) {
++m_jugees;
m_codesJuges.append(code);
});
connect(s, &SocketScheduler::echecRequete, this, [this](const QString &code) {
++m_jugees; // une requête en échec est aussi « jugée » : le cycle n'attend pas plus
m_codesEchec.append(code);
});
QVERIFY(waitFor([s] { return s->client()->state() == OptimizerClient::Ready; }));
}
void TestSocketScheduler::cycle(const QDateTime &t, double compteurW)
{
m_compteur->setStateValue("currentPower", compteurW);
const int avant = m_jugees;
const int requetes = m_requetes.count();
SocketScheduler *s = m_arb->socketScheduler();
const bool envoi = s && s->client()->state() == OptimizerClient::Ready;
m_arb->recordMeterUpdate(t);
m_arb->simulationCallUpdate(t);
if (envoi)
QVERIFY2(waitFor([&] { return m_requetes.count() > requetes; }), "aucune requête GetPlan reçue");
if (envoi && m_repondre)
QVERIFY2(waitFor([&] { return m_jugees > avant; }), "réponse du faux optimiseur jamais jugée");
settle();
}
QVariantMap TestSocketScheduler::plan(const QVariantMap &requete, const QVariantList &actions, int validiteS)
{
const QDateTime ts = QDateTime::fromString(requete.value("timestamp").toString(), Qt::ISODate);
const QVariantMap creneau{{"from", ts.addSecs(-60).toString(Qt::ISODate)},
{"to", ts.addSecs(900).toString(Qt::ISODate)},
{"actions", actions}, {"expected", QVariantMap{{"gridImportW", 0}}}};
return {{"planId", "plan-test"}, {"strategy", "faux"},
{"validUntil", ts.addSecs(validiteS).toString(Qt::ISODate)},
{"slots", QVariantList{creneau}},
{"diagnostics", QVariantMap{{"status", "optimal"}, {"computeMs", 3}}}};
}
void TestSocketScheduler::testNoEndpointNoObject()
{
// Sans endpoint : AUCUN objet, aucune ligne, aucune connexion — la preuve structurelle de
// « identique au bit près ». La preuve comportementale est la référence `decisions`.
monter(false);
{
Journal j;
QVERIFY(!m_arb->socketScheduler());
QVERIFY(m_arb->findChildren<OptimizerClient *>().isEmpty());
QVERIFY(m_arb->findChildren<SocketScheduler *>().isEmpty());
m_arb->relireReglageOptimiseur();
QVERIFY(!m_arb->socketScheduler());
cycle(utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)), -3000);
QVERIFY2(!j.texte().contains("[Optimiseur]"), qUtf8Printable(j.texte()));
QVERIFY(j.texte().contains("[Arbitre]")); // le journal coulait bien
}
// Endpoint illisible : dit UNE fois, rien n'est construit.
{
Journal j;
EnergySettings().setValue("Optimizer/endpoint", "http://optimiseur:80");
m_arb->relireReglageOptimiseur();
m_arb->relireReglageOptimiseur();
QVERIFY(!m_arb->socketScheduler());
QVERIFY(m_arb->findChildren<OptimizerClient *>().isEmpty());
QCOMPARE(j.compte("Optimizer/endpoint illisible"), 1);
}
// Revenu à « aucun endpoint » : de nouveau rien, et pas une ligne.
{
Journal j;
EnergySettings().remove("Optimizer/endpoint");
m_arb->relireReglageOptimiseur();
QVERIFY(!m_arb->socketScheduler());
QVERIFY2(!j.texte().contains("[Optimiseur]"), qUtf8Printable(j.texte()));
}
// Endpoint posé puis RETIRÉ : la stratégie et son client disparaissent avec lui.
QVERIFY(m_dir.isValid());
FakeOptimizer serveur(FakeOptimizer::Unix, m_dir.path());
QVERIFY(serveur.listen());
EnergySettings().setValue("Optimizer/endpoint", serveur.endpoint());
m_arb->relireReglageOptimiseur();
QVERIFY(m_arb->socketScheduler());
QCOMPARE(m_arb->findChildren<OptimizerClient *>().count(), 1);
EnergySettings().remove("Optimizer/endpoint");
m_arb->relireReglageOptimiseur();
QVERIFY(!m_arb->socketScheduler());
QVERIFY(m_arb->findChildren<OptimizerClient *>().isEmpty());
}
void TestSocketScheduler::testValidPlanIsAppliedOnTheNextCycle_data()
{
QTest::addColumn<int>("transport");
QTest::newRow("unix") << int(FakeOptimizer::Unix);
QTest::newRow("tcp") << int(FakeOptimizer::Tcp);
}
void TestSocketScheduler::testValidPlanIsAppliedOnTheNextCycle()
{
QFETCH(int, transport);
monter(true, FakeOptimizer::Transport(transport));
m_repondre = [this](const QVariantMap &req) {
return plan(req, {action("ecs", {{"powerW", 1000}, {"reasonCode", "SURPLUS_SETPOINT"},
{"reasonParams", QVariantMap{{"budgetW", 3000}, {"setpointW", 1000}}}}),
action(m_var, {{"powerW", 1800}, {"reasonCode", "PRICE_SLOT"},
{"reasonParams", QVariantMap{{"priceCtkWh", 16.96}}}})});
};
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
// Cycle 1 : la requête part, rien n'est en cache — le rule-based décide (modèle cache).
Journal j;
cycle(t0, -3000);
QCOMPARE(m_requetes.count(), 1);
const QVariantMap req = m_requetes.first();
QCOMPARE(req.value("trigger").toString(), QString("cycle"));
QCOMPARE(req.value("meter").toMap().value("exportW").toDouble(), 3000.0); // brut, règle 8
QStringList ids;
for (const QVariant &l : req.value("loads").toList())
ids << l.toMap().value("id").toString();
QVERIFY(ids.contains("ecs") && ids.contains(m_var));
// Le fil assemblé par l'arbitre, bout en bout (T1 l'avait laissé au premier appelant) : la
// prévision vient de l'instantané RÉEL (sans site désigné : grille, base et prix, `pv: null`),
// la mesure de chaque charge de mesurerCharge(), le compteur racine de l'EnergyManager.
const QVariantMap prevision = req.value("forecast").toMap();
QVERIFY2(prevision.contains("resolutionS") && prevision.contains("pv"),
QJsonDocument::fromVariant(req.value("forecast")).toJson().constData());
QVERIFY(prevision.value("pv").isNull());
QVERIFY(!req.value("meter").toMap().value("source").toString().isEmpty());
for (const QVariant &l : req.value("loads").toList())
QVERIFY2(l.toMap().value("telemetry").toMap().value("measurement").toMap().contains("source"),
qUtf8Printable(l.toMap().value("id").toString()));
QVERIFY(!m_arb->socketScheduler()->optimiseurAuDernierCycle());
QCOMPARE(m_arb->socketScheduler()->dernierCodeDeRepli(), QString("no-plan"));
const int routeurOmbre = qRound(telemetrie().value(m_var).value("allocatedW").toDouble());
QVERIFY2(routeurOmbre != 1800, "le scénario doit distinguer l'optimiseur de l'ombre");
QVERIFY(m_arb->socketScheduler()->aUnPlanEnCache());
// Cycle 2 : le plan reçu s'applique, par le dispatch commun.
cycle(t0.addSecs(60), -3000);
QVERIFY(m_arb->socketScheduler()->optimiseurAuDernierCycle());
const QHash<QString, QVariantMap> tele = telemetrie();
const QVariantMap ecs = tele.value("ecs"), var = tele.value(m_var);
QCOMPARE(ecs.value("decision").toMap().value("code").toString(), QString(DecisionCode::SurplusSetpoint));
QCOMPARE(ecs.value("decision").toMap().value("level").toString(), QString("optimizer"));
QCOMPARE(qRound(ecs.value("allocatedW").toDouble()), 1000);
QCOMPARE(var.value("decision").toMap().value("code").toString(), QString(DecisionCode::PriceSlot));
QCOMPARE(var.value("decision").toMap().value("params").toMap().value("priceCtkWh").toDouble(), 16.96);
QCOMPARE(qRound(var.value("allocatedW").toDouble()), 1800);
QCOMPARE(var.value("levels").toList().count(), 1);
QCOMPARE(NymeaCore::instance()->thingManager()->findConfiguredThing(m_relais)->stateValue("power").toBool(), true);
// `expected` et `diagnostics` conservés — jamais pour commander.
QCOMPARE(m_arb->socketScheduler()->planApplique().diagnostics.value("status").toString(), QString("optimal"));
// Invariant 12 : le texte libre va au journal, JAMAIS dans la charge utile publiée.
const QByteArray publie = QJsonDocument::fromVariant(m_arb->loadTelemetry()).toJson();
QVERIFY(!publie.contains("LIBRE"));
QVERIFY2(j.texte().contains(kTexteLibre), "le texte de l'optimiseur doit être au journal");
QVERIFY(j.texte().contains("stratégie du cycle : optimiseur"));
}
void TestSocketScheduler::testRefusedPlanFallsBackToRules_data()
{
QTest::addColumn<QString>("cas");
QTest::addColumn<QString>("codeJuge");
QTest::addColumn<QString>("codeCycle");
QTest::newRow("incomplet (Q1)") << "incomplet" << "incomplete-slot" << "no-plan";
QTest::newRow("kind interdit") << "kind" << "kind-not-announced" << "no-plan";
QTest::newRow("loadId inconnu") << "inconnu" << "unknown-load" << "no-plan";
QTest::newRow("reason vide") << "reason" << "missing-reason" << "no-plan";
QTest::newRow("expiré à la réception") << "expire" << "expired" << "no-plan";
QTest::newRow("expiré à l'application") << "expire-tard" << "" << "expired";
QTest::newRow("abstention") << "abstention" << "abstain" << "no-plan";
}
void TestSocketScheduler::testRefusedPlanFallsBackToRules()
{
QFETCH(QString, cas);
QFETCH(QString, codeJuge);
QFETCH(QString, codeCycle);
m_handshake = handshakeResult();
if (cas == "kind") // l'optimiseur n'annonce que `state` : ses consignes en watts sont interdites
m_handshake.insert("capabilities", QJsonObject{{"kinds", QJsonArray{"state"}}});
monter();
m_repondre = [this, cas](const QVariantMap &req) -> QVariantMap {
QVariantMap ecs = action("ecs", {{"powerW", 1000}});
const QVariantMap var = action(m_var, {{"powerW", 1800}});
if (cas == "abstention")
return {{"abstain", true}, {"reason", "recalcul"}};
if (cas == "incomplet")
return plan(req, {ecs});
if (cas == "inconnu")
ecs["loadId"] = "fantome";
if (cas == "reason")
ecs["reason"] = "";
if (cas == "expire")
return plan(req, {ecs, var}, -1);
if (cas == "expire-tard")
return plan(req, {ecs, var}, 30); // valable à la réception, plus au cycle suivant
return plan(req, {ecs, var});
};
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
Journal j;
cycle(t0, -3000);
QCOMPARE(m_codesJuges, QStringList{codeJuge});
if (!codeJuge.isEmpty() && codeJuge != "abstain" && codeJuge != "expired")
QVERIFY(j.texte().contains("plan REFUSÉ")); // 7-d : le refus se voit
cycle(t0.addSecs(60), -3000);
QVERIFY(!m_arb->socketScheduler()->optimiseurAuDernierCycle());
QCOMPARE(m_arb->socketScheduler()->dernierCodeDeRepli(), codeCycle);
// Jamais appliqué EN PARTIE : aucune charge ne porte le niveau de l'optimiseur.
for (const QVariantMap &t : telemetrie())
QVERIFY2(t.value("decision").toMap().value("level").toString() != "optimizer",
qUtf8Printable(t.value("loadId").toString()));
}
void TestSocketScheduler::testOutOfBoundsActionIsClamped()
{
monter();
const QString d = activerTraces();
m_repondre = [this](const QVariantMap &req) {
return plan(req, {action("ecs", {{"powerW", 2500}}), action(m_var, {{"powerW", 0}})});
};
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
Journal j;
cycle(t0, -3000);
cycle(t0.addSecs(60), -3000);
// L'adaptateur écrête (un seul relais de 1000 W) : c'est l'APPLIQUÉ qui est publié.
const QVariantMap ecs = telemetrie().value("ecs");
QCOMPARE(ecs.value("decision").toMap().value("level").toString(), QString("optimizer"));
QCOMPARE(qRound(ecs.value("allocatedW").toDouble()), 1000);
QCOMPARE(qRound(ecs.value("levels").toList().first().toMap().value("targetW").toDouble()), 1000);
QCOMPARE(j.compte("ÉCRÊTÉE par l'adaptateur — demandé 2500 W , appliqué 1000 W"), 1);
// Le même écrêtage au cycle suivant ne se répète pas en clair.
cycle(t0.addSecs(120), -3000);
QCOMPARE(j.compte("ÉCRÊTÉE"), 1);
// T6 : la trace dit l'APPLIQUÉ et le marque `clamped` ; la réponse brute garde la demande.
const QVariantMap ligne = traces(d, 2).at(1);
QCOMPARE(ligne.value("strategy").toString(), QString("optimizer"));
bool vu = false;
for (const QVariant &v : ligne.value("executed").toList()) {
if (v.toMap().value("loadId").toString() != "ecs")
continue;
vu = true;
QCOMPARE(qRound(v.toMap().value("powerW").toDouble()), 1000);
QCOMPARE(v.toMap().value("clamped").toBool(), true);
}
QVERIFY(vu);
const QVariantList demandees = ligne.value("response").toMap().value("result").toMap().value("slots")
.toList().first().toMap().value("actions").toList();
QCOMPARE(qRound(demandees.first().toMap().value("powerW").toDouble()), 2500);
}
void TestSocketScheduler::testUnavailableLoadKeepsTheShadowAction()
{
monter();
// Le relais disparaît : l'ECS sort de l'arbitrage (LOAD_UNAVAILABLE, ECS-111).
QVERIFY(removeDevice(m_relais).toMap().value("params").toMap().value("thingError").toString()
== "ThingErrorNoError");
m_repondre = [this](const QVariantMap &req) {
return plan(req, {action("ecs", {{"powerW", 1000}}), action(m_var, {{"powerW", 1200}})});
};
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
Journal j;
cycle(t0, -3000);
QCOMPARE(m_codesJuges, QStringList{QString()}); // valide : Q1 n'exige que les charges disponibles
cycle(t0.addSecs(60), -3000);
QVERIFY(m_arb->socketScheduler()->optimiseurAuDernierCycle());
const QHash<QString, QVariantMap> tele = telemetrie();
QCOMPARE(tele.value("ecs").value("decision").toMap().value("code").toString(),
QString(DecisionCode::LoadUnavailable));
QCOMPARE(tele.value(m_var).value("decision").toMap().value("level").toString(), QString("optimizer"));
QCOMPARE(qRound(tele.value(m_var).value("allocatedW").toDouble()), 1200);
QVERIFY(j.texte().contains("ecs : charge INDISPONIBLE — action de l'optimiseur rejetée"));
// Q1 n'exige QUE les charges disponibles : un plan qui ne nomme pas l'ECS en panne est valide.
m_repondre = [this](const QVariantMap &req) { return plan(req, {action(m_var, {{"powerW", 1200}})}); };
cycle(t0.addSecs(120), -3000);
QCOMPARE(m_codesJuges.last(), QString());
cycle(t0.addSecs(180), -3000);
QVERIFY(m_arb->socketScheduler()->optimiseurAuDernierCycle());
}
void TestSocketScheduler::testReasonIsFilteredOnTheWayToTheApp()
{
monter();
m_repondre = [this](const QVariantMap &req) {
return plan(req, {action("ecs", {{"powerW", 1000}, {"reasonCode", "PRICE_SLOT"},
{"reasonParams", QVariantMap{{"priceCtkWh", 12.5},
{"rang", 3}, // non documentée
{"setpointW", "mille watts"}}}}),
action(m_var, {{"powerW", 500}, {"reasonCode", "DEGRADED_L2"},
{"reasonParams", QVariantMap{{"x", 1}}}})});
};
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
Journal j;
cycle(t0, -3000);
cycle(t0.addSecs(60), -3000);
const QHash<QString, QVariantMap> tele = telemetrie();
const QVariantMap ecs = tele.value("ecs").value("decision").toMap();
QCOMPARE(ecs.value("code").toString(), QString(DecisionCode::PriceSlot));
QCOMPARE(ecs.value("params").toMap(), (QVariantMap{{"priceCtkWh", 12.5}}));
// Hors liste blanche : un optimiseur ne parle pas au nom de la sécurité.
const QVariantMap var = tele.value(m_var).value("decision").toMap();
QCOMPARE(var.value("code").toString(), QString(DecisionCode::OptimizerDecision));
QVERIFY(var.value("params").toMap().isEmpty());
// Une clé non documentée (même numérique) et une valeur texte : supprimées, et dites.
QVERIFY(j.texte().contains("paramètres supprimés : rang,setpointW"));
QVERIFY(j.texte().contains("reçu : DEGRADED_L2 → OPTIMIZER_DECISION"));
QVERIFY(!QJsonDocument::fromVariant(m_arb->loadTelemetry()).toJson().contains("mille watts"));
}
void TestSocketScheduler::testL4ShedsALoadTheOptimizerCommands()
{
monter();
QCOMPARE(injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", 25}}))
.toMap().value("params").toMap().value("energyError").toString(),
QString("EnergyErrorNoError"));
m_repondre = [this](const QVariantMap &req) {
return plan(req, {action("ecs", {{"powerW", 1000}}), action(m_var, {{"powerW", 0}})});
};
for (const char *p : {"currentPhaseA", "currentPhaseB", "currentPhaseC"})
m_compteur->setStateValue(p, 0);
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
cycle(t0, -3000);
cycle(t0.addSecs(60), -3000);
Thing *relais = NymeaCore::instance()->thingManager()->findConfiguredThing(m_relais);
QCOMPARE(relais->stateValue("power").toBool(), true); // l'optimiseur l'a enclenché
// Surcharge : L4 déleste, et il a le dernier mot sur le plan de l'optimiseur, toujours en cache.
m_compteur->setStateValue("currentPhaseA", 30);
cycle(t0.addSecs(70), 6900);
QVERIFY(m_arb->socketScheduler()->optimiseurAuDernierCycle());
QCOMPARE(relais->stateValue("power").toBool(), false);
QCOMPARE(telemetrie().value("ecs").value("decision").toMap().value("code").toString(),
QString(DecisionCode::DrawCap));
}
void TestSocketScheduler::testDegradedModeSuspendsPlanning()
{
monter();
m_repondre = [this](const QVariantMap &req) {
return plan(req, {action("ecs", {{"powerW", 1000}}), action(m_var, {{"powerW", 500}})});
};
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
cycle(t0, -3000);
cycle(t0.addSecs(60), -3000);
QVERIFY(m_arb->socketScheduler()->optimiseurAuDernierCycle());
const int requetes = m_requetes.count();
// Compteur muet > 90 s : mode dégradé L2, la planification est suspendue — AUCUNE stratégie,
// ni requête ni application, quel que soit le plan en cache.
m_arb->evaluateMeterFreshness(t0.addSecs(200));
QVERIFY(m_arb->degradedMode());
m_arb->simulationCallUpdate(t0.addSecs(240));
settle(200);
QCOMPARE(m_requetes.count(), requetes);
QCOMPARE(NymeaCore::instance()->thingManager()->findConfiguredThing(m_relais)->stateValue("power").toBool(),
false); // le repli L2 a coupé l'ECS, et personne ne l'a rallumé
}
void TestSocketScheduler::testBudgetAndDrawAreRecomputedFromTheApplied()
{
monter();
m_repondre = [this](const QVariantMap &req) {
return plan(req, {action("ecs", {{"powerW", 1000}}), action(m_var, {{"powerW", 1500}})});
};
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
cycle(t0, -500);
const QVariantMap drawOmbre = m_arb->loadTelemetry().value("draw").toMap();
cycle(t0.addSecs(60), -500);
QVERIFY(m_arb->socketScheduler()->optimiseurAuDernierCycle());
const QVariantMap snap = m_arb->loadTelemetry();
const QVariantMap budget = snap.value("budget").toMap(), draw = snap.value("draw").toMap();
// Q4 — la mesure, puis ce qui a été APPLIQUÉ : 2500 W appliqués sur 500 W d'export.
QCOMPARE(budget.value("surplusW").toDouble(), 500.0);
QCOMPARE(budget.value("evReservedW").toDouble(), 0.0);
const double surplus = budget.value("surplusW").toDouble() + budget.value("recreditedW").toDouble();
QCOMPARE(qRound(surplus - budget.value("allocatedW").toDouble()), qRound(budget.value("remainingW").toDouble()));
QCOMPARE(qRound(budget.value("allocatedW").toDouble() + draw.value("committedW").toDouble()), 2500);
QCOMPARE(qRound(budget.value("allocatedW").toDouble()), qRound(qMin(surplus, 2500.0)));
QVERIFY(draw.value("committedW").toDouble() > 0);
QVERIFY2(qRound(drawOmbre.value("committedW").toDouble()) == 0, "l'ombre n'achetait rien");
// Les registres se réconcilient par charge : Σ counts == budget.allocatedW et draw.committedW.
double alloue = 0, achete = 0;
for (const QVariantMap &t : telemetrie()) {
for (const QVariant &n : t.value("levels").toList()) {
const QVariantMap c = n.toMap().value("counts").toMap();
alloue += c.value(CountKey::BudgetAllocated).toDouble();
achete += c.value(CountKey::DrawCommitted).toDouble();
}
// Q3 — financement dérivé de la mesure : ici l'export ne couvre pas, le reliquat est acheté.
QCOMPARE(t.value("levels").toList().count(), 1);
}
QCOMPARE(qRound(alloue), qRound(budget.value("allocatedW").toDouble()));
QCOMPARE(qRound(achete), qRound(draw.value("committedW").toDouble()));
QCOMPARE(telemetrie().value(m_var).value("levels").toList().first().toMap().value("funding").toString(),
QString("grid"));
}
void TestSocketScheduler::testTheTriggerNamesTheCause()
{
monter();
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
cycle(t0, -3000);
QCOMPARE(m_requetes.last().value("trigger").toString(), QString("cycle"));
// Une configuration réécrite (SetLoadConfig → reconstruction) : `config`.
const QVariantList cfg = injectAndWait("NymeaEnergy.GetLoadConfig").toMap().value("params").toMap()
.value("loadConfigs").toList();
QVERIFY(!cfg.isEmpty());
QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", cfg}}))
.toMap().value("params").toMap().value("energyError").toString(),
QString("EnergyErrorNoError"));
cycle(t0.addSecs(60), -3000);
QCOMPARE(m_requetes.last().value("trigger").toString(), QString("config"));
// Consommé : le cycle suivant redevient `cycle`.
cycle(t0.addSecs(120), -3000);
QCOMPARE(m_requetes.last().value("trigger").toString(), QString("cycle"));
// Un Thing ajouté : `load-change`.
addPowerSwitch(500, 26662);
cycle(t0.addSecs(180), -3000);
QCOMPARE(m_requetes.last().value("trigger").toString(), QString("load-change"));
// Plusieurs causes : la plus significative l'emporte.
m_arb->socketScheduler()->noterDeclencheur("forecast");
m_arb->socketScheduler()->noterDeclencheur("intent");
m_arb->socketScheduler()->noterDeclencheur("config");
cycle(t0.addSecs(240), -3000);
QCOMPARE(m_requetes.last().value("trigger").toString(), QString("intent"));
}
void TestSocketScheduler::testANewInvalidPlanReplacesTheCache()
{
// Un plan valide est en cache ; la réponse suivante est invalide. Elle ne s'applique pas, et
// l'ancien non plus : un optimiseur qui vient de répondre faux n'est pas cru sur sa réponse
// d'avant (§7 — le compteur d'échecs viendra en T4, le repli est immédiat).
monter();
m_repondre = [this](const QVariantMap &req) {
return plan(req, {action("ecs", {{"powerW", 1000}}), action(m_var, {{"powerW", 500}})});
};
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
cycle(t0, -3000);
m_repondre = [this](const QVariantMap &req) { return plan(req, {action("ecs", {{"powerW", 1000}})}); };
cycle(t0.addSecs(60), -3000);
QVERIFY(m_arb->socketScheduler()->optimiseurAuDernierCycle()); // le premier plan, appliqué
QCOMPARE(m_codesJuges.last(), QString("incomplete-slot"));
cycle(t0.addSecs(120), -3000);
QVERIFY(!m_arb->socketScheduler()->optimiseurAuDernierCycle());
QCOMPARE(m_arb->socketScheduler()->dernierCodeDeRepli(), QString("no-plan"));
}
void TestSocketScheduler::testConnectionLossDropsThePlan()
{
// §7 « socket absent / fermé → rules » : un plan ne survit pas à la connexion qui l'a porté.
monter();
m_repondre = [this](const QVariantMap &req) {
return plan(req, {action("ecs", {{"powerW", 1000}}), action(m_var, {{"powerW", 500}})});
};
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
cycle(t0, -3000);
cycle(t0.addSecs(60), -3000);
QVERIFY(m_arb->socketScheduler()->optimiseurAuDernierCycle());
m_serveur->responder = nullptr; // plus de handshake : la connexion ne revient pas
m_serveur->abortClient();
SocketScheduler *s = m_arb->socketScheduler();
QVERIFY(waitFor([s] { return s->client()->state() != OptimizerClient::Ready; }));
QVERIFY(!s->aUnPlanEnCache());
cycle(t0.addSecs(120), -3000);
QVERIFY(!s->optimiseurAuDernierCycle());
QCOMPARE(s->dernierCodeDeRepli(), QString("no-plan"));
}
void TestSocketScheduler::testThePlanOfAnOptimizerBuysNothingEco()
{
// Correctif c, cas (vii) — sous un plan d'optimiseur, l'ombre rule-based tourne encore et
// calcule sa cible éco ; elle n'est pas exécutée. L'acheté se compte sur ce qui a été
// APPLIQUÉ, et une action d'optimiseur (niveau unique `optimizer`) n'a pas de part éco : rien
// n'est compté, même quand le compteur de la charge avance.
monter();
ThingManager *tm = NymeaCore::instance()->thingManager();
const QUuid compteurId = addMeter(26664);
Thing *compteur = tm->findConfiguredThing(compteurId);
QVERIFY(compteur);
compteur->setStateValue("connected", true);
compteur->setStateValue("currentPower", 0); // le test pose lui-même l'énergie
compteur->setStateValue("totalEnergyConsumed", 10.0);
auto ajouterWh = [&](double wh) {
compteur->setStateValue("totalEnergyConsumed",
compteur->stateValue("totalEnergyConsumed").toDouble() + wh / 1000.0);
};
// L'ECS reçoit une obligation (2 000 Wh d'ici 07:00, soit 1 000 W à 05:00 : son relais
// entier) et un compteur d'énergie. Le routeur, sans obligation, ne sert que Q1.
QVariantList l;
l.append(QVariantMap{{"id", "ecs"}, {"label", "ECS"}, {"adapter", "relay-router"}, {"mode", "fixed"},
{"priority", 1}, {"enabled", true}, {"minOnS", 0}, {"minOffS", 0},
{"relays", QVariantList() << QVariantMap{{"thingId", m_relais.toString()}, {"powerW", 1000}}},
{"meterThingId", compteurId.toString()},
{"needs", QVariantMap{{"dailyDeadline", "07:00"}, {"minEnergyWhPerDay", 2000}}}});
l.append(QVariantMap{{"id", m_var}, {"label", "Routeur"}, {"adapter", "etmvariableload"},
{"mode", "dynamic"}, {"maxPowerW", 3000}, {"priority", 2}, {"enabled", true}});
QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", l}}))
.toMap().value("params").toMap().value("energyError").toString(),
QString("EnergyErrorNoError"));
m_repondre = [this](const QVariantMap &req) {
return plan(req, {action("ecs", {{"powerW", 1000}}), action(m_var, {{"powerW", 0}})});
};
auto eco = [this]() {
for (const QVariant &n : telemetrie().value("ecs").value("levels").toList())
if (n.toMap().value("level").toString() == QLatin1String("eco"))
return n.toMap().value("progress").toMap();
return QVariantMap();
};
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(5, 0, 0));
cycle(t0, 2000); // rule-based : éco 1 000 W, relais fermé
QVERIFY(!m_arb->socketScheduler()->optimiseurAuDernierCycle());
QCOMPARE(eco().value("purchasedWh").toDouble(), 0.0);
cycle(t0.addSecs(60), 2000); // le plan de l'optimiseur s'applique
QVERIFY(m_arb->socketScheduler()->optimiseurAuDernierCycle());
QCOMPARE(tm->findConfiguredThing(m_relais)->stateValue("power").toBool(), true);
// 250 Wh passent pendant que l'OPTIMISEUR commande ; puis il répond faux, et l'on revient
// aux règles — le seul moment où le niveau éco, donc l'acheté, se publie de nouveau.
ajouterWh(250);
m_repondre = [this](const QVariantMap &req) { return plan(req, {action("ecs", {{"powerW", 1000}})}); };
cycle(t0.addSecs(120), 2000); // plan d'avant appliqué ; le nouveau, refusé
QVERIFY(m_arb->socketScheduler()->optimiseurAuDernierCycle());
cycle(t0.addSecs(180), 2000);
QVERIFY(!m_arb->socketScheduler()->optimiseurAuDernierCycle());
const QVariantMap p = eco();
QCOMPARE(p.value("regime").toString(), QString("measured"));
QCOMPARE(qRound(p.value("deliveredWh").toDouble()), 250); // l'énergie est bien arrivée…
QVERIFY2(p.contains("purchasedWh"), "sous measured, l'acheté est publié");
QCOMPARE(p.value("purchasedWh").toDouble(), 0.0); // …et l'éco n'en a rien acheté
}
void TestSocketScheduler::testPvIsReadFromAnyProducer()
{
// `pv.currentW` se lit dès qu'un `smartmeterproducer` existe — pas seulement un
// `solarinverter`, qui l'ÉTEND dans libnymea. Le producteur du mock est exactement le cas
// du banc : un compteur de production (sdm120Producer) posé sur un onduleur non
// communicant, `smartmeterproducer` et rien d'autre.
monter();
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
EnergyManagerMock *em = dynamic_cast<EnergyManagerMock *>(m_experiencePlugin->energyManager());
QVERIFY(em);
em->setCurrentPowerProduction(-2400); // convention nymea : produite = négative
// Sans producteur : inconnu (null), même si l'EnergyManager rend un chiffre — 0 W ne
// distinguerait pas la nuit de l'absence de PV.
cycle(t0, -3000);
QVariantMap pv = m_requetes.last().value("pv").toMap();
QVERIFY(pv.contains("currentW"));
QVERIFY(pv.value("currentW").isNull());
const QUuid producteur = addSolarInverter();
Thing *t = NymeaCore::instance()->thingManager()->findConfiguredThing(producteur);
QVERIFY(t);
QVERIFY2(t->thingClass().interfaces().contains("smartmeterproducer")
&& !t->thingClass().interfaces().contains("solarinverter"),
"le cas éprouvé est un producteur qui N'EST PAS un onduleur");
cycle(t0.addSecs(60), -3000);
pv = m_requetes.last().value("pv").toMap();
QVERIFY2(!pv.value("currentW").isNull(), "un smartmeterproducer seul doit suffire");
QCOMPARE(pv.value("currentW").toDouble(), 2400.0);
}
void TestSocketScheduler::testFallbackMachineIsPure()
{
using E = OptimizerStatus::Vivacite::Evenement;
OptimizerStatus::Vivacite v;
const QDateTime t = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
// Faux tant qu'aucun plan valide n'a été reçu : un optimiseur jamais entendu n'est pas vivant.
QVERIFY(!v.vivant());
QVERIFY(!v.noter(E::Echec, "timeout"));
QCOMPARE(v.echecsConsecutifs(), 1);
QVERIFY(v.noter(E::PlanValide, QString(), t));
QVERIFY(v.vivant());
QCOMPARE(v.echecsConsecutifs(), 0);
QCOMPARE(v.dernierPlanA(), t);
QCOMPARE(v.dernierCodeErreur(), QString("timeout")); // le dernier code reste lisible
// Abstention : vivant inchangé, et la série d'échecs est ROMPUE (« consécutifs »).
QVERIFY(!v.noter(E::Echec, "rpc-error"));
QVERIFY(v.vivant());
QVERIFY(!v.noter(E::Abstention));
QCOMPARE(v.echecsConsecutifs(), 0);
QVERIFY(!v.noter(E::Echec, "rpc-error"));
QVERIFY(v.vivant()); // un échec isolé ne tue pas
QVERIFY(v.noter(E::Echec, "incomplete-slot")); // le second, oui (E17 : par requête)
QVERIFY(!v.vivant());
QCOMPARE(v.echecsConsecutifs(), 2);
QCOMPARE(v.dernierCodeErreur(), QString("incomplete-slot"));
// Seul un plan valide rend la vie : une abstention dit qu'il répond, pas qu'il planifie.
QVERIFY(!v.noter(E::Abstention));
QVERIFY(!v.vivant());
QVERIFY(v.noter(E::PlanValide, QString(), t.addSecs(60)));
// Socket fermé : mort tout de suite, sans attendre deux requêtes.
QVERIFY(v.noter(E::ConnexionPerdue, "connection-lost"));
QVERIFY(!v.vivant());
QCOMPARE(v.dernierCodeErreur(), QString("connection-lost"));
// La colonne « stratégie active » : L2 d'abord, puis la vivacité.
QCOMPARE(OptimizerStatus::strategieActive(true, true, "optimizer"), QString("none"));
QCOMPARE(OptimizerStatus::strategieActive(true, false, "rules"), QString("none"));
QCOMPARE(OptimizerStatus::strategieActive(false, false, "optimizer"), QString("rules"));
QCOMPARE(OptimizerStatus::strategieActive(false, true, "optimizer"), QString("optimizer"));
QCOMPARE(OptimizerStatus::strategieActive(false, true, "rules"), QString("rules"));
QCOMPARE(OptimizerStatus::strategieActive(false, true, "none"), QString("none")); // L2 levé, pas encore replanifié
// Ce qu'un optimiseur écrit librement n'atteint pas l'app (mode installateur).
const QVariantMap cap = OptimizerStatus::capacitesPubliees(QVariantMap{
{"maxHorizonH", 48}, {"resolutionS", "3600"}, {"kinds", QVariantList{"setpoint", "fusee", 3}},
{"usesForecast", QVariantList{"pv", "astrologie"}}, {"reasonCodes", true}, {"libre", kTexteLibre}});
QCOMPARE(cap, (QVariantMap{{"maxHorizonH", 48.0}, {"kinds", QVariantList{"setpoint"}},
{"usesForecast", QVariantList{"pv"}}, {"reasonCodes", true}}));
QCOMPARE(OptimizerStatus::identite(QString("etm\x1b" "optimizer\n v2"), 64), QString("etm optimizer v2"));
QCOMPARE(OptimizerStatus::identite(QString(100, 'x'), 64).size(), 64);
QVERIFY(OptimizerStatus::identite(QString("\x07\x1b"), 64).isEmpty());
QCOMPARE(PublishedPlan::attenduPublie(QVariantMap{{"gridImportW", 0}, {"gridExportW", 350.5},
{"note", "texte"}, {"batterySocPercent", 40}}),
(QVariantMap{{"gridImportW", 0.0}, {"gridExportW", 350.5}}));
QCOMPARE(PublishedPlan::diagnosticsPublies(QVariantMap{
{"status", "optimal"}, {"solver", "highs"}, {"computeMs", 3},
{"inputsUsed", QVariantMap{{"pv", "hybrid"}, {"baseload", "none"}}}}),
(QVariantMap{{"status", "optimal"}, {"inputsUsed", QVariantMap{{"pv", "hybrid"}}}}));
QVERIFY(PublishedPlan::diagnosticsPublies(QVariantMap{
{"status", "presque"}, {"inputsUsed", QVariantMap{{"pv", kTexteLibre}}}}).isEmpty());
}
void TestSocketScheduler::testStatusWithoutEndpoint()
{
// §7, ligne « optimizerExpected = false » : rien n'est attendu, rien n'est vivant, le
// rule-based commande. Ce qui n'a pas de valeur est ABSENT, jamais 0.
monter(false);
enableNotifications({"NymeaEnergy"});
QSignalSpy paquets(m_mockTcpServer, &MockTcpServer::outgoingData);
QSignalSpy spy(m_arb, &EnergyArbitrator::optimizerStatusChanged);
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
Journal j;
QCOMPARE(rpc("GetPlan"), (QVariantMap{{"inForce", false}})); // aucun cycle encore
cycle(t0, -3000);
cycle(t0.addSecs(60), -3000);
const QVariantMap st = rpc("GetOptimizerStatus");
QCOMPARE(st, (QVariantMap{{"optimizerExpected", false}, {"optimizerAlive", false},
{"activeStrategy", "rules"}, {"tokenConfigured", false}}));
QCOMPARE(spy.count(), 0); // un cycle n'est pas une transition
// Le plan en vigueur : celui, à un créneau, du rule-based. Pas de fallbackCode : rien n'a été
// refusé, il n'y a personne d'autre — c'est ce qui distingue « pas d'optimiseur ».
const QVariantMap p = rpc("GetPlan");
QCOMPARE(p.value("inForce").toBool(), true);
QCOMPARE(p.value("strategy").toString(), QString("rules"));
QCOMPARE(p.value("computedAt").toString(), t0.addSecs(60).toUTC().toString(Qt::ISODate));
QVERIFY(p.contains("validUntil"));
QVERIFY(!p.contains("fallbackCode") && !p.contains("diagnostics"));
QCOMPARE(p.value("slots").toList().count(), 1);
QStringList ids;
for (const QVariant &a : p.value("slots").toList().first().toMap().value("actions").toList()) {
ids << a.toMap().value("loadId").toString();
QVERIFY(!a.toMap().value("reasonCode").toString().isEmpty());
QVERIFY(!a.toMap().contains("reason"));
}
QVERIFY2(ids.contains("ecs") && ids.contains(m_var), qUtf8Printable(ids.join(',')));
// L2 l'emporte sur tout, même sans optimiseur : `none`, UNE notification, plus de plan.
m_arb->evaluateMeterFreshness(t0.addSecs(200));
QCOMPARE(triplet(), QString("-|mort|none"));
m_arb->evaluateMeterFreshness(t0.addSecs(260));
QCOMPARE(triplets(spy), QStringList{"-|mort|none"});
QCOMPARE(rpc("GetPlan"), (QVariantMap{{"inForce", false}}));
// Sortie de L2 : la planification reprend au CYCLE, et la stratégie avec elle — d'ici là,
// rien n'a été planifié, `none` tient.
m_arb->recordMeterUpdate(t0.addSecs(290));
QVERIFY(!m_arb->degradedMode());
QCOMPARE(triplet(), QString("-|mort|none"));
cycle(t0.addSecs(300), -3000);
QCOMPARE(triplets(spy), (QStringList{"-|mort|none", "-|mort|rules"}));
QCOMPARE(rpc("GetPlan").value("strategy").toString(), QString("rules"));
// La notification JSON-RPC porte la charge utile du Get, une fois par transition.
const QVariantList notifs = checkNotifications(paquets, "NymeaEnergy.OptimizerStatusChanged");
QCOMPARE(notifs.count(), 2);
// Comparées en JSON : c'est la forme qui voyage (48 y revient en entier, pas en double).
QCOMPARE(QJsonObject::fromVariantMap(notifs.last().toMap().value("params").toMap()),
QJsonObject::fromVariantMap(m_arb->optimizerStatus()));
// Sans endpoint, le moteur ne dit rien de l'optimiseur.
QVERIFY2(!j.texte().contains("[Optimiseur]"), qUtf8Printable(j.texte()));
}
void TestSocketScheduler::testStatusFollowsTheFallbackTable_data()
{
QTest::addColumn<QString>("cas");
QTest::addColumn<QString>("attendu"); // triplet après l'événement
QTest::addColumn<int>("echecs"); // consecutiveFailures
QTest::addColumn<QString>("code"); // lastErrorCode, vide = absent
QTest::newRow("plan valide") << "valide" << "attendu|vivant|optimizer" << 0 << "";
QTest::newRow("abstention") << "abstention" << "attendu|vivant|rules" << 0 << "";
QTest::newRow("plan expiré") << "expire" << "attendu|vivant|rules" << 0 << "";
QTest::newRow("un échec") << "un-echec" << "attendu|vivant|optimizer" << 1 << "rpc-error";
QTest::newRow("deux échecs") << "deux-echecs" << "attendu|mort|rules" << 2 << "rpc-error";
QTest::newRow("plan invalide ×2") << "invalide" << "attendu|mort|rules" << 2 << "incomplete-slot";
QTest::newRow("requête remplacée") << "remplacee" << "attendu|vivant|optimizer" << 0 << "";
QTest::newRow("socket fermé") << "ferme" << "attendu|mort|rules" << 0 << "connection-lost";
QTest::newRow("mode dégradé L2") << "l2" << "attendu|vivant|none" << 0 << "";
}
void TestSocketScheduler::testStatusFollowsTheFallbackTable()
{
QFETCH(QString, cas);
QFETCH(QString, attendu);
QFETCH(int, echecs);
QFETCH(QString, code);
monter();
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
// Plan expiré : chaque plan vaut 90 s, et l'optimiseur se tait ensuite.
amenerAOptimiseur(t0, cas == "expire" ? 90 : 1800);
SocketScheduler *s = m_arb->socketScheduler();
if (cas == "abstention") {
m_repondre = [](const QVariantMap &) { return QVariantMap{{"abstain", true}, {"reason", "recalcul"}}; };
cycle(t0.addSecs(120), -3000);
cycle(t0.addSecs(180), -3000);
QCOMPARE(s->dernierCodeDeRepli(), QString("no-plan"));
} else if (cas == "expire") {
m_repondre = nullptr;
cycle(t0.addSecs(200), -3000); // le plan reçu à t0 + 60 valait jusqu'à t0 + 150
QCOMPARE(s->dernierCodeDeRepli(), QString("expired"));
} else if (cas == "un-echec" || cas == "deux-echecs") {
m_repondre = [](const QVariantMap &) { return QVariantMap{{"__erreur", true}}; };
cycle(t0.addSecs(120), -3000);
QVERIFY(s->optimiseurAuDernierCycle()); // un échec isolé : le plan en cache tient
if (cas == "deux-echecs") {
cycle(t0.addSecs(180), -3000);
QVERIFY(!s->aUnPlanEnCache()); // mort : le cache n'est plus cru
}
} else if (cas == "invalide") {
m_repondre = [this](const QVariantMap &req) { return plan(req, {action("ecs", {{"powerW", 1000}})}); };
cycle(t0.addSecs(120), -3000);
QCOMPARE(triplet(), QString("attendu|vivant|optimizer")); // un seul plan invalide
cycle(t0.addSecs(180), -3000);
} else if (cas == "remplacee") {
// Un optimiseur qui ne répond pas avant le cycle suivant : chaque requête est REMPLACÉE
// par la suivante. Ce n'est pas un échec (T2) — seul le délai de 10 s en ferait un.
m_repondre = nullptr;
cycle(t0.addSecs(120), -3000);
cycle(t0.addSecs(180), -3000);
cycle(t0.addSecs(240), -3000);
QVERIFY(s->optimiseurAuDernierCycle());
} else if (cas == "ferme") {
m_serveur->responder = nullptr;
m_serveur->abortClient();
QVERIFY(waitFor([s] { return s->client()->state() != OptimizerClient::Ready; }));
QVERIFY(!s->aUnPlanEnCache());
} else if (cas == "l2") {
m_arb->evaluateMeterFreshness(t0.addSecs(200));
QVERIFY(m_arb->degradedMode());
}
QCOMPARE(triplet(), attendu);
const QVariantMap st = rpc("GetOptimizerStatus");
QCOMPARE(QJsonObject::fromVariantMap(st), QJsonObject::fromVariantMap(m_arb->optimizerStatus()));
QCOMPARE(st.value("consecutiveFailures").toInt(), echecs);
QCOMPARE(st.value("lastErrorCode").toString(), code);
QCOMPARE(st.contains("lastErrorCode"), !code.isEmpty()); // inconnu = absent, jamais ""
// Identité du handshake, en mode installateur ; lastPlanAt = cycle de la requête du plan.
QCOMPARE(st.value("name").toString(), m_handshake.value("name").toString());
QCOMPARE(st.value("protocolVersion").toString(), m_handshake.value("protocolVersion").toString());
QVERIFY(st.value("lastPlanAt").toString().endsWith("Z"));
QCOMPARE(st.value("tokenConfigured").toBool(), false);
}
void TestSocketScheduler::testEachTransitionNotifiesOnce()
{
monter();
enableNotifications({"NymeaEnergy"});
QSignalSpy paquets(m_mockTcpServer, &MockTcpServer::outgoingData);
QSignalSpy spy(m_arb, &EnergyArbitrator::optimizerStatusChanged);
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
auto t = [&](int s) { return t0.addSecs(s); };
const auto abstention = [](const QVariantMap &) { return QVariantMap{{"abstain", true}, {"reason", "recalcul"}}; };
const auto erreur = [](const QVariantMap &) { return QVariantMap{{"__erreur", true}}; };
// Vivant : le plan valide rend la vie à sa RÉCEPTION, la stratégie suit au cycle qui l'applique.
m_repondre = planValide();
cycle(t(0), -3000);
cycle(t(60), -3000);
// Abstention RÉPÉTÉE : une seule transition vers rules.
m_repondre = abstention;
for (int s = 120; s <= 300; s += 60)
cycle(t(s), -3000);
m_repondre = planValide();
cycle(t(360), -3000);
cycle(t(420), -3000);
// Vivant → mort (deux requêtes en échec) → vivant.
m_repondre = erreur;
cycle(t(480), -3000);
cycle(t(540), -3000);
cycle(t(600), -3000); // un troisième échec : déjà mort, rien à dire
m_repondre = planValide(90);
cycle(t(660), -3000);
cycle(t(720), -3000);
// Plan expiré, puis nouveau plan.
m_repondre = nullptr;
cycle(t(840), -3000);
QCOMPARE(m_arb->socketScheduler()->dernierCodeDeRepli(), QString("expired"));
m_repondre = planValide();
cycle(t(900), -3000);
cycle(t(960), -3000);
// L2 pendant l'optimiseur, puis sortie : vivant inchangé, le plan en cache reprend.
m_arb->evaluateMeterFreshness(t(1160));
m_arb->evaluateMeterFreshness(t(1200));
cycle(t(1220), -3000);
const QStringList attendu{
"attendu|vivant|rules", // plan valide reçu, pas encore appliqué
"attendu|vivant|optimizer",
"attendu|vivant|rules", // abstention ×4 : une fois
"attendu|vivant|optimizer",
"attendu|mort|rules", // second échec consécutif
"attendu|vivant|rules", // premier plan valide
"attendu|vivant|optimizer",
"attendu|vivant|rules", // plan expiré
"attendu|vivant|optimizer", // nouveau plan
"attendu|vivant|none", // L2, prioritaire
"attendu|vivant|optimizer", // sortie de L2, premier cycle planifié
};
QCOMPARE(triplets(spy), attendu);
// Le transport : une notification JSON-RPC par transition, charge utile du Get.
const QVariantList notifs = checkNotifications(paquets, "NymeaEnergy.OptimizerStatusChanged");
QCOMPARE(notifs.count(), attendu.count());
// Comparées en JSON : c'est la forme qui voyage (48 y revient en entier, pas en double).
// `lastPlanAt` a avancé depuis (un plan de plus, sans transition) : c'est voulu, la
// notification ne suit que les transitions, le Get rend l'état du moment.
QJsonObject notifie = QJsonObject::fromVariantMap(notifs.last().toMap().value("params").toMap());
QJsonObject lu = QJsonObject::fromVariantMap(m_arb->optimizerStatus());
QVERIFY(notifie.contains("lastPlanAt") && lu.value("lastPlanAt") != notifie.value("lastPlanAt"));
notifie.remove("lastPlanAt");
lu.remove("lastPlanAt");
QCOMPARE(notifie, lu);
}
void TestSocketScheduler::testEndpointIsSetByRpcAndAppliedHot()
{
monter(false);
enableNotifications({"NymeaEnergy"});
QSignalSpy spy(m_arb, &EnergyArbitrator::optimizerStatusChanged);
QVERIFY(m_dir.isValid());
FakeOptimizer unixSrv(FakeOptimizer::Unix, m_dir.path());
FakeOptimizer tcpSrv(FakeOptimizer::Tcp, m_dir.path());
QVERIFY(unixSrv.listen() && tcpSrv.listen());
const QJsonObject hs = handshakeResult();
for (FakeOptimizer *f : {&unixSrv, &tcpSrv})
f->responder = [f, hs](const QJsonObject &m) {
if (m.value("method").toString() != QLatin1String("Handshake"))
return false;
f->replyResult(m.value("id").toInteger(), hs);
return true;
};
auto fixer = [this](const QString &e) { return rpc("SetOptimizerEndpoint", {{"endpoint", e}}); };
auto reglage = [] { return EnergySettings().value("Optimizer/endpoint"); };
// Illisible : refusé, DIT, et rien ne change.
Journal j;
QVariantMap r = fixer("http://optimiseur:80");
QCOMPARE(r.value("energyError").toString(), QString("EnergyErrorInvalidParameter"));
QCOMPARE(r.value("fault").toString(), QString("invalid-endpoint"));
QVERIFY(!reglage().isValid());
QVERIFY(!m_arb->socketScheduler());
QCOMPARE(j.compte("endpoint REFUSÉ"), 1);
QCOMPARE(spy.count(), 0);
// unix:// : persisté, appliqué à chaud, le client se connecte.
r = fixer(unixSrv.endpoint());
QCOMPARE(r.value("energyError").toString(), QString("EnergyErrorNoError"));
QCOMPARE(reglage().toString(), unixSrv.endpoint());
SocketScheduler *s = m_arb->socketScheduler();
QVERIFY(s);
QVERIFY(waitFor([s] { return s->client()->state() == OptimizerClient::Ready; }));
QCOMPARE(triplet(), QString("attendu|mort|rules"));
QCOMPARE(triplets(spy), QStringList{"attendu|mort|rules"});
QCOMPARE(rpc("GetOptimizerStatus").value("name").toString(), hs.value("name").toString());
// Un endpoint illisible pendant qu'un autre est posé : refusé, l'autre reste.
r = fixer("tcp://sans-port");
QCOMPARE(r.value("fault").toString(), QString("invalid-endpoint"));
QCOMPARE(m_arb->socketScheduler(), s);
QCOMPARE(reglage().toString(), unixSrv.endpoint());
// tcp:// : la stratégie est reconstruite vers le nouvel endpoint.
r = fixer(tcpSrv.endpoint());
QCOMPARE(r.value("energyError").toString(), QString("EnergyErrorNoError"));
s = m_arb->socketScheduler();
QVERIFY(s);
QVERIFY(waitFor([s] { return s->client()->state() == OptimizerClient::Ready; }));
QCOMPARE(m_arb->findChildren<OptimizerClient *>().count(), 1);
QCOMPARE(tcpSrv.connections, 1);
// Vide : retiré du réglage, stratégie et client détruits, plus rien n'est attendu.
r = fixer("");
QCOMPARE(r.value("energyError").toString(), QString("EnergyErrorNoError"));
QVERIFY(!reglage().isValid());
QVERIFY(!m_arb->socketScheduler());
QVERIFY(m_arb->findChildren<OptimizerClient *>().isEmpty());
QCOMPARE(rpc("GetOptimizerStatus"), (QVariantMap{{"optimizerExpected", false}, {"optimizerAlive", false},
{"activeStrategy", "rules"}, {"tokenConfigured", false}}));
QCOMPARE(triplets(spy), (QStringList{"attendu|mort|rules", "-|mort|rules"}));
}
void TestSocketScheduler::testTokenIsWriteOnlyAndNeverLeaks()
{
monter();
m_repondre = planValide();
const QString secret = QStringLiteral("T4-secret-qui-ne-sort-jamais-0123456789abcdef");
const QString chemin = OptimizerClient::defaultTokenPath();
QFile::remove(chemin);
QSignalSpy paquets(m_mockTcpServer, &MockTcpServer::outgoingData);
// TOUT le journal, tous niveaux, toutes catégories — nymea compris.
QLoggingCategory::setFilterRules(QStringLiteral("*.debug=true"));
Journal j;
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
QCOMPARE(rpc("GetOptimizerStatus").value("tokenConfigured").toBool(), false);
QVariantMap r = rpc("SetOptimizerToken", {{"token", secret}});
QCOMPARE(r.value("energyError").toString(), QString("EnergyErrorNoError"));
QCOMPARE(r.keys(), QStringList{"energyError"});
QFile f(chemin);
QVERIFY(f.open(QIODevice::ReadOnly));
QCOMPARE(QString::fromUtf8(f.readAll()).trimmed(), secret);
f.close();
const QFileDevice::Permissions autres = QFileDevice::ExeOwner | QFileDevice::ReadGroup | QFileDevice::WriteGroup
| QFileDevice::ExeGroup | QFileDevice::ReadOther | QFileDevice::WriteOther | QFileDevice::ExeOther;
QVERIFY(QFile::permissions(chemin) & QFileDevice::ReadOwner);
QVERIFY(QFile::permissions(chemin) & QFileDevice::WriteOwner);
QCOMPARE(QFile::permissions(chemin) & autres, QFileDevice::Permissions()); // 0600
QCOMPARE(rpc("GetOptimizerStatus").value("tokenConfigured").toBool(), true);
QVERIFY(!EnergySettings().allKeys().join(' ').contains("token", Qt::CaseInsensitive));
// Le secret TRANSITE bien : à la reconnexion suivante, le handshake le porte.
const int handshakes = m_handshakes.count();
m_serveur->abortClient();
QVERIFY(waitFor([this, handshakes] { return m_handshakes.count() > handshakes; }, 8000));
QCOMPARE(m_handshakes.last().value("token").toString(), secret);
SocketScheduler *s = m_arb->socketScheduler();
QVERIFY(waitFor([s] { return s->client()->state() == OptimizerClient::Ready; }));
cycle(t0, -3000);
cycle(t0.addSecs(60), -3000);
QVERIFY(s->optimiseurAuDernierCycle());
rpc("GetPlan");
rpc("GetOptimizerStatus");
// Refus : pas une chaîne, un blanc — rien ne change, le secret reste.
r = injectAndWait("NymeaEnergy.SetOptimizerToken", {{"token", 31415926}}).toMap().value("params").toMap();
QCOMPARE(r.value("fault").toString(), QString("token-refused"));
r = rpc("SetOptimizerToken", {{"token", "avec blanc"}});
QCOMPARE(r.value("fault").toString(), QString("token-refused"));
QVERIFY(f.open(QIODevice::ReadOnly));
QCOMPARE(QString::fromUtf8(f.readAll()).trimmed(), secret);
f.close();
// Retrait : fichier supprimé, tokenConfigured faux.
QCOMPARE(rpc("SetOptimizerToken", {{"token", ""}}).value("energyError").toString(), QString("EnergyErrorNoError"));
QVERIFY(!QFile::exists(chemin));
QCOMPARE(rpc("GetOptimizerStatus").value("tokenConfigured").toBool(), false);
QVERIFY(j.texte().contains("token d'optimiseur posé") && j.texte().contains("token d'optimiseur retiré"));
QVERIFY(j.texte().contains("token REFUSÉ"));
// Aucune RÉPONSE ni notification ne contient le secret.
for (const QList<QVariant> &p : paquets)
QVERIFY2(!p.last().toByteArray().contains(secret.toUtf8()), p.last().toByteArray().left(200).constData());
QVERIFY(paquets.count() > 5); // le flux coulait
// Aucun journal du MOTEUR ne le contient. Il n'apparaît que dans la trace BRUTE des données
// entrantes de nymea (« Incoming data: », catégorie JsonRpcTraffic, niveau debug, éteinte par
// défaut) — hors de portée du plugin, comme pour le token d'authentification nymea lui-même.
auto horsTrafic = [&j](const QString &fragment) {
QStringList l;
for (const QString &x : j.lignes(fragment))
if (!x.startsWith(QLatin1String("JsonRpcTraffic: ")))
l << x.left(200);
return l;
};
QVERIFY2(horsTrafic(secret).isEmpty(), qUtf8Printable(horsTrafic(secret).join('\n')));
QVERIFY2(horsTrafic("31415926").isEmpty(), qUtf8Printable(horsTrafic("31415926").join('\n')));
QVERIFY2(!j.lignes(secret).isEmpty(),
"la trace JsonRpcTraffic de nymea ne recopie plus les données entrantes : retirer la mise "
"en garde d'INTERFACE.md (SetOptimizerToken)");
// Ce que nymea fait d'une clé EN TROP : le validateur refuse l'appel avant le handler et
// recopie les paramètres dans un AVERTISSEMENT (« Call params: », catégorie JsonRpc). Épinglé
// ici parce que c'est la seule fuite possible au journal par défaut, et qu'elle est hors du
// plugin : si ce test casse, nymea a cessé de le faire — retirer la mise en garde d'INTERFACE.md.
const QString autre = QStringLiteral("T4-autre-secret-cle-en-trop");
injectAndWait("NymeaEnergy.SetOptimizerToken", {{"token", autre}, {"cle", 1}});
QVERIFY(!j.lignes(autre).filter(QRegularExpression("^JsonRpc: ")).isEmpty());
QVERIFY(!QFile::exists(chemin)); // l'appel n'est jamais arrivé au handler
QLoggingCategory::setFilterRules(QStringLiteral(
"*.debug=false\nApplication.debug=true\nSimulation.debug=true\nExperiences.debug=true\n"
"NymeaEnergy.debug=true\nEnergyMocks.debug=true\nDBus.warning=false"));
}
void TestSocketScheduler::testGetPlanPublishesThePlanInForce()
{
monter();
m_repondre = [this](const QVariantMap &req) {
const QDateTime ts = QDateTime::fromString(req.value("timestamp").toString(), Qt::ISODate);
QVariantMap p = plan(req, {action("ecs", {{"powerW", 2500}, {"reasonCode", "SURPLUS_SETPOINT"},
{"reasonParams", QVariantMap{{"budgetW", 3000}, {"note", "texte libre"}}}}),
action(m_var, {{"powerW", 1800}})});
QVariantList creneaux = p.value("slots").toList();
QVariantMap c0 = creneaux.first().toMap();
c0.insert("expected", QVariantMap{{"gridImportW", 0}, {"gridExportW", 350}, {"commentaire", kTexteLibre}});
creneaux[0] = c0;
creneaux.append(QVariantMap{{"from", ts.addSecs(900).toString(Qt::ISODate)},
{"to", ts.addSecs(1800).toString(Qt::ISODate)},
{"actions", QVariantList{action("ecs", {{"powerW", 0}}),
action(m_var, {{"powerW", 3000}})}}});
p.insert("slots", creneaux);
p.insert("computedAt", ts.toString(Qt::ISODate));
p.insert("diagnostics", QVariantMap{{"status", "optimal"}, {"solver", "highs"}, {"computeMs", 3},
{"inputsUsed", QVariantMap{{"pv", "hybrid"}, {"baseload", "none"}}}});
return p;
};
auto parId = [](const QVariantMap &creneau) {
QHash<QString, QVariantMap> h;
for (const QVariant &a : creneau.value("actions").toList())
h.insert(a.toMap().value("loadId").toString(), a.toMap());
return h;
};
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
// Cycle 1 : un optimiseur est configuré mais n'a pas commandé — « pas de plan ce cycle ».
cycle(t0, -3000);
QVariantMap p = rpc("GetPlan");
QCOMPARE(p.value("strategy").toString(), QString("rules"));
QCOMPARE(p.value("fallbackCode").toString(), QString("no-plan"));
QCOMPARE(p.value("slots").toList().count(), 1);
// Cycle 2 : le plan de l'optimiseur EN VIGUEUR.
cycle(t0.addSecs(60), -3000);
QVERIFY(m_arb->socketScheduler()->optimiseurAuDernierCycle());
p = rpc("GetPlan");
QCOMPARE(p.value("strategy").toString(), QString("optimizer"));
QVERIFY(!p.contains("fallbackCode"));
QCOMPARE(p.value("computedAt").toString(), t0.toUTC().toString(Qt::ISODate));
QCOMPARE(p.value("validUntil").toString(), t0.addSecs(1800).toUTC().toString(Qt::ISODate));
QCOMPARE(p.value("diagnostics").toMap(),
(QVariantMap{{"status", "optimal"}, {"inputsUsed", QVariantMap{{"pv", "hybrid"}}}}));
const QVariantList creneaux = p.value("slots").toList();
QCOMPARE(creneaux.count(), 2);
const QVariantMap c0 = creneaux.at(0).toMap(), c1 = creneaux.at(1).toMap();
QCOMPARE(c0.value("from").toString(), t0.addSecs(-60).toUTC().toString(Qt::ISODate));
QCOMPARE(c0.value("expected").toMap(), (QVariantMap{{"gridImportW", 0.0}, {"gridExportW", 350.0}}));
QVERIFY(!c1.contains("expected"));
// Créneau courant : la puissance APPLIQUÉE (un relais de 1000 W, 2500 demandés) ;
// créneau suivant : la puissance DEMANDÉE.
QCOMPARE(qRound(parId(c0).value("ecs").value("powerW").toDouble()), 1000);
QCOMPARE(qRound(parId(c0).value(m_var).value("powerW").toDouble()), 1800);
QCOMPARE(qRound(parId(c1).value("ecs").value("powerW").toDouble()), 0);
QCOMPARE(qRound(parId(c1).value(m_var).value("powerW").toDouble()), 3000);
// Motifs : codes et paramètres FILTRÉS seulement (invariant 12).
QCOMPARE(parId(c0).value("ecs").value("reasonCode").toString(), QString(DecisionCode::SurplusSetpoint));
QCOMPARE(parId(c0).value("ecs").value("reasonParams").toMap(), (QVariantMap{{"budgetW", 3000}}));
QCOMPARE(parId(c0).value(m_var).value("reasonCode").toString(), QString(DecisionCode::OptimizerDecision));
QCOMPARE(parId(c1).value(m_var).value("reasonCode").toString(), QString(DecisionCode::OptimizerDecision));
const QByteArray json = QJsonDocument::fromVariant(p).toJson();
for (const char *interdit : {"LIBRE", "texte libre", "highs", "plan-test", "\"faux\"", "\"reason\"", "commentaire"})
QVERIFY2(!json.contains(interdit), interdit);
// L2 : aucun plan en vigueur.
m_arb->evaluateMeterFreshness(t0.addSecs(260));
QCOMPARE(rpc("GetPlan"), (QVariantMap{{"inForce", false}}));
}
void TestSocketScheduler::testGetPlanShowsTheAppliedPowerUnderALock()
{
// Le créneau courant publie ce que l'adaptateur a APPLIQUÉ, pas ce que la stratégie a
// demandé. Sous l'optimiseur les deux coïncident déjà dans le créneau (finaliser(), T3) ;
// le cas qui les sépare est le rule-based face à un verrou interne à l'adaptateur.
monter(false);
QVariantList l;
l.append(QVariantMap{{"id", "ecs"}, {"label", "ECS"}, {"adapter", "relay-router"}, {"mode", "fixed"},
{"priority", 1}, {"enabled", true}, {"minOnS", 600}, {"minOffS", 0},
{"relays", QVariantList() << QVariantMap{{"thingId", m_relais.toString()}, {"powerW", 1000}}}});
QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", l}}))
.toMap().value("params").toMap().value("energyError").toString(),
QString("EnergyErrorNoError"));
auto ecs = [this]() {
for (const QVariant &a : rpc("GetPlan").value("slots").toList().first().toMap().value("actions").toList())
if (a.toMap().value("loadId").toString() == QLatin1String("ecs"))
return a.toMap();
return QVariantMap();
};
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
cycle(t0, -3000);
QCOMPARE(qRound(ecs().value("powerW").toDouble()), 1000);
Thing *relais = NymeaCore::instance()->thingManager()->findConfiguredThing(m_relais);
QCOMPARE(relais->stateValue("power").toBool(), true);
// Import : la stratégie veut couper, le verrou minOn (600 s) tient le relais fermé.
cycle(t0.addSecs(60), 2500);
QCOMPARE(relais->stateValue("power").toBool(), true);
QCOMPARE(qRound(ecs().value("powerW").toDouble()), 1000);
}
void TestSocketScheduler::testUpdateLearnedIsLoggedOnceAndIgnored()
{
// §8 : reçu, journalisé UNE fois, ignoré — la persistance est un lot ultérieur.
monter();
amenerAOptimiseur(utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)));
const QVariantMap avant = m_arb->optimizerStatus();
Journal j;
const QJsonObject n{{"jsonrpc", "2.0"}, {"method", "UpdateLearned"},
{"params", QJsonObject{{"learned", QJsonObject{{"ecsWhPerDay", 4321}}},
{"confidence", 0.5}, {"note", kTexteLibre}}}};
m_serveur->sendJson(n);
m_serveur->sendJson(n);
settle(200);
QCOMPARE(j.compte("UpdateLearned reçu et IGNORÉ"), 1);
QVERIFY(!j.texte().contains(kTexteLibre) && !j.texte().contains("4321")); // le contenu n'est pas écrit
QCOMPARE(m_arb->socketScheduler()->client()->state(), OptimizerClient::Ready);
QCOMPARE(m_arb->optimizerStatus(), avant);
// Ignoré : rien n'en revient dans la requête suivante.
cycle(utcDateTime(QDate(2026, 6, 8), QTime(13, 2, 0)), -3000);
const QByteArray req = QJsonDocument::fromVariant(m_requetes.last()).toJson();
QVERIFY(!req.contains("4321") && !req.contains("learned"));
}
// --- Brief 4 T6 : traces de décision (OPTIMIZER_PROTOCOL annexe D) ----------------------------
QString TestSocketScheduler::activerTraces()
{
static int n = 0;
const QString d = m_dossierTraces.path() + QStringLiteral("/traces-%1").arg(++n);
QDir().mkpath(d);
m_arb->setOptimizerTraceDirectory(d);
const QVariantMap r = rpc("SetOptimizerTrace", {{"enabled", true}});
[&] { QCOMPARE(r.value("energyError").toString(), QString("EnergyErrorNoError")); }();
[&] { QVERIFY(m_arb->optimizerTracer()); }();
return d;
}
QStringList TestSocketScheduler::fichiersTrace(const QString &dossier)
{
return QDir(dossier).entryList({"etm-powersync-optimizer-trace-*.ndjson"}, QDir::Files, QDir::Name);
}
void TestSocketScheduler::exporterTraces(const QString &dossier, const QString &nom)
{
const QString vers = qEnvironmentVariable("ETM_POWERSYNC_TRACE_EXPORT");
if (vers.isEmpty())
return;
QDir().mkpath(vers);
for (const QString &f : fichiersTrace(dossier)) {
QFile::remove(QDir(vers).filePath(nom + "-" + f));
QFile::copy(QDir(dossier).filePath(f), QDir(vers).filePath(nom + "-" + f));
}
}
QList<QVariantMap> TestSocketScheduler::traces(const QString &dossier, int n)
{
QList<QVariantMap> lignes;
auto lire = [&] {
lignes.clear();
for (const QString &nom : fichiersTrace(dossier)) {
QFile f(QDir(dossier).filePath(nom));
if (!f.open(QIODevice::ReadOnly))
continue;
for (const QByteArray &l : f.readAll().split('\n'))
if (!l.isEmpty())
lignes.append(QJsonDocument::fromJson(l).object().toVariantMap());
}
return lignes.count() >= n;
};
waitFor(lire, 3000);
return lignes;
}
void TestSocketScheduler::testTraceIsOffByDefault()
{
// Défaut : DÉSACTIVÉ. Ni traceur, ni fil, ni fichier, ni ligne de journal.
monter(false);
QVERIFY(!m_arb->optimizerTracer());
QVERIFY(m_arb->findChildren<OptimizerTracer *>().isEmpty());
const QVariantMap g = rpc("GetOptimizerTrace");
QCOMPARE(g.value("enabled").toBool(), false);
QCOMPARE(g.value("retentionDays").toInt(), 14);
QCOMPARE(g.value("maxBytesPerDay").toLongLong(), OptimizerTrace::PlafondDefautOctets);
QCOMPARE(g.value("directory").toString(), NymeaSettings::storagePath());
Journal j;
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
cycle(t0, -3000);
cycle(t0.addSecs(60), -3000);
settle(200);
QVERIFY(fichiersTrace(NymeaSettings::storagePath()).isEmpty());
QVERIFY(!j.texte().contains("[Trace]"));
QVERIFY(j.texte().contains("[Arbitre]")); // le journal coulait bien
}
void TestSocketScheduler::testTraceSettingByRpc()
{
monter(false);
// Refus : une valeur hors bornes ou du mauvais type ne change RIEN, pas même les autres clés
// de l'appel (le type est contrôlé par le moteur : le validateur Qt 6 ne le fait pas).
const QList<QVariantMap> refus{
{{"retentionDays", 0}}, {{"retentionDays", 366}}, {{"retentionDays", 1.5}},
{{"retentionDays", "14"}}, {{"maxBytesPerDay", 1000}}, {{"maxBytesPerDay", 2147483647}},
{{"enabled", "yes"}}, {{"enabled", true}, {"retentionDays", 0}}};
for (const QVariantMap &p : refus) {
const QVariantMap r = rpc("SetOptimizerTrace", p);
QCOMPARE(r.value("energyError").toString(), QString("EnergyErrorInvalidParameter"));
QCOMPARE(r.value("fault").toString(), QString("invalid-trace-setting"));
QVERIFY(!m_arb->optimizerTracer());
QCOMPARE(rpc("GetOptimizerTrace").value("retentionDays").toInt(), 14);
QVERIFY(!EnergySettings().contains("Optimizer/trace"));
}
// Pose : persistée dans energy.conf, appliquée à chaud.
const QString d = m_dossierTraces.path() + "/reglage";
QDir().mkpath(d);
m_arb->setOptimizerTraceDirectory(d);
QVariantMap r = rpc("SetOptimizerTrace", {{"enabled", true}, {"retentionDays", 3}, {"maxBytesPerDay", 2097152}});
QCOMPARE(r.value("energyError").toString(), QString("EnergyErrorNoError"));
OptimizerTracer *tr = m_arb->optimizerTracer();
QVERIFY(tr);
QCOMPARE(tr->reglage().retentionJours, 3);
QCOMPARE(tr->reglage().plafondOctetsJour, qint64(2097152));
QCOMPARE(EnergySettings().value("Optimizer/trace").toBool(), true);
QCOMPARE(EnergySettings().value("Optimizer/traceRetentionDays").toInt(), 3);
QCOMPARE(EnergySettings().value("Optimizer/traceMaxBytesPerDay").toLongLong(), 2097152ll);
QCOMPARE(QJsonObject::fromVariantMap(rpc("GetOptimizerTrace")),
QJsonObject::fromVariantMap({{"enabled", true}, {"retentionDays", 3},
{"maxBytesPerDay", 2097152}, {"directory", d}}));
// Une clé seule : les autres restent, le traceur n'est pas reconstruit.
rpc("SetOptimizerTrace", {{"retentionDays", 7}});
QCOMPARE(m_arb->optimizerTracer(), tr);
QCOMPARE(tr->reglage().retentionJours, 7);
QCOMPARE(tr->reglage().plafondOctetsJour, qint64(2097152));
// Désactivation : le traceur disparaît, la ligne de réglage reste lisible.
rpc("SetOptimizerTrace", {{"enabled", false}});
QVERIFY(!m_arb->optimizerTracer());
QCOMPARE(rpc("GetOptimizerTrace").value("enabled").toBool(), false);
QCOMPARE(rpc("GetOptimizerTrace").value("retentionDays").toInt(), 7);
}
void TestSocketScheduler::testTraceLineUnderRulesAndInL2()
{
// Sans endpoint : la requête qui SERAIT partie, et le plan du rule-based comme réponse.
monter(false);
const QString d = activerTraces();
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
cycle(t0, -3000);
cycle(t0.addSecs(60), -3000);
QList<QVariantMap> l = traces(d, 2);
QCOMPARE(l.count(), 2);
const QVariantMap a = l.last();
QStringList cles = a.keys();
cles.sort();
QCOMPARE(cles, (QStringList{"executed", "request", "response", "strategy", "t"}));
QCOMPARE(a.value("t").toString(), OptimizerTrace::horodatage(t0.addSecs(60)));
QCOMPARE(QDateTime::fromString(a.value("t").toString(), Qt::ISODate), t0.addSecs(60));
// L'heure LOCALE, décalage explicite (annexe D) — pas l'UTC.
QCOMPARE(QDateTime::fromString(a.value("t").toString(), Qt::ISODate).offsetFromUtc(),
t0.toLocalTime().offsetFromUtc());
QVERIFY(!a.value("t").toString().endsWith('Z'));
QCOMPARE(a.value("strategy").toString(), QString("rules"));
const QVariantMap req = a.value("request").toMap();
QCOMPARE(req.value("trigger").toString(), QString("cycle"));
QCOMPARE(req.value("meter").toMap().value("exportW").toDouble(), 3000.0);
QVERIFY(req.contains("forecast") && req.contains("timestamp"));
QStringList ids;
for (const QVariant &c : req.value("loads").toList())
ids << c.toMap().value("id").toString();
QVERIFY(ids.contains("ecs") && ids.contains(m_var));
const QVariantMap rep = a.value("response").toMap().value("result").toMap();
QCOMPARE(rep.value("strategy").toString(), QString("rules"));
QCOMPARE(rep.value("slots").toList().count(), 1);
const QHash<QString, QVariantMap> tele = telemetrie();
for (const QVariant &v : rep.value("slots").toList().first().toMap().value("actions").toList()) {
const QVariantMap act = v.toMap();
QVERIFY(!act.value("reasonCode").toString().isEmpty());
QVERIFY(!act.contains("reason")); // des codes, jamais de texte
}
QCOMPARE(a.value("executed").toList().count(), tele.count());
for (const QVariant &v : a.value("executed").toList()) {
const QVariantMap e = v.toMap();
QVERIFY2(tele.contains(e.value("loadId").toString()), qUtf8Printable(e.value("loadId").toString()));
QCOMPARE(e.value("kind").toString(), QString("setpoint"));
QCOMPARE(qRound(e.value("powerW").toDouble()),
qRound(tele.value(e.value("loadId").toString()).value("allocatedW").toDouble()));
QCOMPARE(e.value("clamped").toBool(), false);
}
QVERIFY(!a.contains("latencyMs"));
// L2 : une ligne `none`, SANS requête ni réponse, rien d'exécuté par la planification.
m_arb->evaluateMeterFreshness(t0.addSecs(200));
QVERIFY(m_arb->degradedMode());
m_arb->simulationCallUpdate(t0.addSecs(240));
l = traces(d, 3);
QCOMPARE(l.count(), 3);
QCOMPARE(l.last(), (QVariantMap{{"t", OptimizerTrace::horodatage(t0.addSecs(240))},
{"executed", QVariantList()}, {"strategy", "none"}}));
exporterTraces(d, "regles");
}
void TestSocketScheduler::testTraceLineUnderTheOptimizer()
{
monter();
const QString d = activerTraces();
m_repondre = planValide();
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
cycle(t0, -3000);
cycle(t0.addSecs(60), -3000);
const QList<QVariantMap> l = traces(d, 2);
QCOMPARE(l.count(), 2);
QCOMPARE(m_requetes.count(), 2);
for (int i = 0; i < 2; ++i) {
// La requête RÉELLEMENT envoyée, et la réponse BRUTE à CETTE requête, avec sa latence.
QCOMPARE(QJsonObject::fromVariantMap(l.at(i).value("request").toMap()),
QJsonObject::fromVariantMap(m_requetes.at(i)));
QCOMPARE(QJsonObject::fromVariantMap(l.at(i).value("response").toMap().value("result").toMap()),
QJsonObject::fromVariantMap(planValide()(m_requetes.at(i))));
QVERIFY(l.at(i).value("latencyMs").toLongLong() >= 0);
// Le texte libre de l'optimiseur n'est QUE dans la réponse brute.
QVariantMap sansReponse = l.at(i);
sansReponse.remove("response");
QVERIFY(!QJsonDocument::fromVariant(sansReponse).toJson().contains("LIBRE"));
}
// Cycle 1 : rien en cache, le rule-based a commandé ; cycle 2 : le plan reçu au cycle 1.
QCOMPARE(l.at(0).value("strategy").toString(), QString("rules"));
QCOMPARE(l.at(1).value("strategy").toString(), QString("optimizer"));
QHash<QString, double> puissances;
for (const QVariant &v : l.at(1).value("executed").toList()) {
puissances.insert(v.toMap().value("loadId").toString(), v.toMap().value("powerW").toDouble());
QCOMPARE(v.toMap().value("clamped").toBool(), false);
}
QCOMPARE(puissances.value("ecs"), 1000.0);
QCOMPARE(puissances.value(m_var), 500.0);
exporterTraces(d, "optimiseur");
}
void TestSocketScheduler::testTraceAbstentionFailureAndNoResponse()
{
monter();
const QString d = activerTraces();
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
m_repondre = [](const QVariantMap &) { return QVariantMap{{"abstain", true}, {"reason", "recalcul"}}; };
cycle(t0, -3000);
m_repondre = [](const QVariantMap &) { return QVariantMap{{"__erreur", true}}; };
cycle(t0.addSecs(60), -3000);
m_repondre = nullptr; // ne répond plus : la requête sera remplacée sans réponse
cycle(t0.addSecs(120), -3000);
cycle(t0.addSecs(180), -3000);
const QList<QVariantMap> l = traces(d, 3);
QCOMPARE(l.count(), 3);
QCOMPARE(l.at(0).value("response").toMap(),
(QVariantMap{{"result", QVariantMap{{"abstain", true}, {"reason", "recalcul"}}}}));
QCOMPARE(l.at(1).value("response").toMap(),
(QVariantMap{{"error", QVariantMap{{"code", "rpc-error"}}}}));
QVERIFY(l.at(1).contains("latencyMs"));
QCOMPARE(l.at(2).value("response").toMap(),
(QVariantMap{{"error", QVariantMap{{"code", "no-response"}}}}));
QVERIFY(!l.at(2).contains("latencyMs"));
for (const QVariantMap &x : l) {
QCOMPARE(x.value("strategy").toString(), QString("rules"));
QVERIFY(x.contains("request") && !x.value("executed").toList().isEmpty());
}
// La 4e ligne attend encore sa réponse : elle part au cycle suivant, ou quand l'arbitre
// abandonne les traces (désactivation) — jamais perdue en silence.
rpc("SetOptimizerTrace", {{"enabled", false}});
QCOMPARE(traces(d, 4).last().value("response").toMap(),
(QVariantMap{{"error", QVariantMap{{"code", "no-response"}}}}));
exporterTraces(d, "repli");
}
void TestSocketScheduler::testTraceRotatesAtLocalMidnightAndPurges()
{
monter(false);
const QString d = activerTraces();
const QDate jour(2026, 6, 8);
auto poser = [&](const QString &nom) {
QFile f(QDir(d).filePath(nom));
QVERIFY(f.open(QIODevice::WriteOnly));
f.write("{}\n");
};
poser(OptimizerTrace::nomFichier(jour.addDays(-14))); // hors rétention : purgé
poser(OptimizerTrace::nomFichier(jour.addDays(-13))); // dans la rétention (14 jours, ce jour compris)
poser("etm-powersync-optimizer-trace-pas-une-date.ndjson");
poser("autre.ndjson");
Journal j;
// 23:59 puis 00:00:30 HEURE LOCALE : la rotation suit le jour local du cycle.
cycle(QDateTime(jour, QTime(23, 59, 0)), -3000);
traces(d, 3);
QCOMPARE(fichiersTrace(d), (QStringList{OptimizerTrace::nomFichier(jour.addDays(-13)),
OptimizerTrace::nomFichier(jour),
"etm-powersync-optimizer-trace-pas-une-date.ndjson"}));
cycle(QDateTime(jour.addDays(1), QTime(0, 0, 30)), -3000);
traces(d, 4);
QCOMPARE(fichiersTrace(d), (QStringList{OptimizerTrace::nomFichier(jour),
OptimizerTrace::nomFichier(jour.addDays(1)),
"etm-powersync-optimizer-trace-pas-une-date.ndjson"}));
QVERIFY(QFile::exists(QDir(d).filePath("autre.ndjson")));
QFile f(QDir(d).filePath(OptimizerTrace::nomFichier(jour.addDays(1))));
QVERIFY(f.open(QIODevice::ReadOnly));
const QVariantMap ligne = QJsonDocument::fromJson(f.readLine()).object().toVariantMap();
QCOMPARE(QDateTime::fromString(ligne.value("t").toString(), Qt::ISODate).toLocalTime().date(), jour.addDays(1));
// Rétention réglée à chaud : 1 jour = le fichier du jour seul.
rpc("SetOptimizerTrace", {{"retentionDays", 1}});
QVERIFY(waitFor([&] { return !fichiersTrace(d).contains(OptimizerTrace::nomFichier(jour)); }));
QCOMPARE(j.compte("[Trace]"), j.compte("[Trace] 1 fichier(s)") + j.compte("[Trace] réglage appliqué"));
}
void TestSocketScheduler::testTraceCapStopsUntilTheNextDay()
{
monter(false);
const QString d = activerTraces();
rpc("SetOptimizerTrace", {{"maxBytesPerDay", OptimizerTrace::PlafondMinOctets}});
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
const QString chemin = QDir(d).filePath(OptimizerTrace::nomFichier(t0.toLocalTime().date()));
{
QFile f(chemin);
QVERIFY(f.open(QIODevice::WriteOnly));
f.write(QByteArray(OptimizerTrace::PlafondMinOctets - 100, 'x') + '\n');
}
const qint64 avant = QFileInfo(chemin).size();
Journal j;
for (int i = 0; i < 4; ++i)
cycle(t0.addSecs(60 * i), -3000);
settle(300);
QCOMPARE(QFileInfo(chemin).size(), avant); // plus rien ce jour-là
QCOMPARE(j.compte("plafond journalier atteint"), 1);
// Le lendemain : de nouveau écrit.
cycle(t0.addDays(1), -3000);
const QString demain = QDir(d).filePath(OptimizerTrace::nomFichier(t0.addDays(1).toLocalTime().date()));
QVERIFY(waitFor([&] { return QFileInfo(demain).size() > 0; }));
QCOMPARE(j.compte("plafond journalier atteint"), 1);
}
void TestSocketScheduler::testTraceWriteFailureChangesNoDecision()
{
if (::geteuid() == 0)
QSKIP("root écrit dans un répertoire en lecture seule");
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
const QList<double> compteur{-3000, -1500, -500, -2200};
// Ce que décide chaque cycle, par RÔLE de charge (l'identifiant du routeur change d'un banc à
// l'autre) : puissance allouée et code du motif.
auto releve = [&] {
QStringList r;
const QHash<QString, QVariantMap> t = telemetrie();
for (const QString &id : {QStringLiteral("ecs"), m_var})
r << QStringLiteral("%1:%2").arg(qRound(t.value(id).value("allocatedW").toDouble()))
.arg(t.value(id).value("decision").toMap().value("code").toString());
return r.join('|');
};
monter(false);
QStringList reference;
for (int i = 0; i < compteur.count(); ++i) {
cycle(t0.addSecs(60 * i), compteur.at(i));
reference << releve();
}
monter(false);
const QString d = activerTraces();
QVERIFY(QFile::setPermissions(d, QFileDevice::ReadOwner | QFileDevice::ExeOwner));
Journal j;
QStringList obtenu;
for (int i = 0; i < compteur.count(); ++i) {
cycle(t0.addSecs(60 * i), compteur.at(i));
obtenu << releve();
}
settle(300);
QCOMPARE(obtenu, reference);
QVERIFY(fichiersTrace(d).isEmpty());
QCOMPARE(j.compte("[Trace] écriture impossible"), 1);
// Le répertoire redevient inscriptible : l'écriture reprend, et c'est dit une fois.
QVERIFY(QFile::setPermissions(d, QFileDevice::ReadOwner | QFileDevice::WriteOwner | QFileDevice::ExeOwner));
cycle(t0.addSecs(600), -3000);
QCOMPARE(traces(d, 1).count(), 1);
QCOMPARE(j.compte("[Trace] écriture rétablie"), 1);
QCOMPARE(j.compte("[Trace] écriture impossible"), 1);
}
void TestSocketScheduler::testTokenIsInNoTrace()
{
monter();
// Un secret avec les deux caractères que JSON échappe : le masquage doit le reconnaître écrit.
const QString secret = QStringLiteral("T6-secret\"qui\\ne-sort-pas-0123456789");
QCOMPARE(rpc("SetOptimizerToken", {{"token", secret}}).value("energyError").toString(),
QString("EnergyErrorNoError"));
const QString d = activerTraces();
// Un optimiseur qui RECOPIE le token dans sa réponse (motif, identifiant de plan).
m_repondre = [this, secret](const QVariantMap &req) {
QVariantMap p = plan(req, {action("ecs", {{"powerW", 1000}, {"reason", "écho " + secret}}),
action(m_var, {{"powerW", 500}})});
p.insert("planId", secret);
return p;
};
Journal j;
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
cycle(t0, -3000);
cycle(t0.addSecs(60), -3000);
QCOMPARE(traces(d, 2).count(), 2);
QByteArray tout;
for (const QString &nom : fichiersTrace(d)) {
QFile f(QDir(d).filePath(nom));
QVERIFY(f.open(QIODevice::ReadOnly));
tout += f.readAll();
}
const QByteArray echappe = QJsonDocument(QJsonArray{secret}).toJson(QJsonDocument::Compact).mid(2).chopped(2);
QVERIFY(!tout.contains(secret.toUtf8()));
QVERIFY(!tout.contains(echappe));
QVERIFY(!tout.contains("0123456789")); // pas même un morceau
QCOMPARE(tout.count("[token masqué]"), 4); // 2 réponses × (motif + planId)
QCOMPARE(j.compte("recopie le token"), 1);
// La requête ne l'a jamais porté : il ne voyage que dans le handshake.
for (const QVariantMap &r : std::as_const(m_requetes))
QVERIFY(!QJsonDocument::fromVariant(r).toJson().contains("0123456789"));
QFile::remove(OptimizerClient::defaultTokenPath());
}
void TestSocketScheduler::testTraceLineSize()
{
// MESURE (brief 4 T6, banc Pi Zero 2 W sur carte SD) : la taille d'une ligne au CONTEXTE
// COMPLET — la requête réelle d'un cycle, sa prévision portée à 48 créneaux horaires (PV par
// plan, base, météo, prix import/export), quatre charges — sous le rule-based et sous un
// optimiseur qui rend 48 créneaux. Sérialisée comme l'écrit l'écrivain (JSON compact).
monter();
m_repondre = planValide();
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
cycle(t0, -3000);
QVariantMap req = m_requetes.first();
auto serie = [](double base, double pas) {
QVariantList l;
for (int i = 0; i < 48; ++i)
l << qRound((base + pas * ((i * 7) % 13)) * 10) / 10.0;
return l;
};
QVariantList plans;
for (int k = 0; k < 2; ++k)
plans << QVariantMap{{"id", QUuid::createUuid().toString()}, {"label", k ? "Toit ouest" : "Toit sud"},
{"peakW", 4500}, {"tiltDeg", 35}, {"azimuthDeg", k ? 90 : 0},
{"corrections", QVariantList{"temperature"}}, {"p50W", serie(1200, 310)}};
req.insert("forecast", QVariantMap{
{"start", t0.toString(Qt::ISODate)}, {"resolutionS", 3600}, {"steps", 48},
{"pv", QVariantMap{{"provider", "open-meteo"}, {"weatherModel", "meteofrance_seamless"},
{"conversion", "physics"}, {"corrections", QVariantList{"temperature", "ac-limit"}},
{"issuedAt", t0.toString(Qt::ISODate)}, {"contentChangedAt", t0.toString(Qt::ISODate)},
{"acLimitW", 8000}, {"p50W", serie(2400, 620)}, {"perField", plans}}},
{"baseload", QVariantMap{{"model", "profile"}, {"p50W", serie(380, 45)},
{"quality", QVariantMap{{"windowDays", 28}, {"minutes", 28800}}}}},
{"weather", QVariantMap{{"temperatureC", serie(8, 1.3)}}},
{"price", QVariantMap{{"currency", "EUR"}, {"importCtkWh", serie(18.5, 2.1)},
{"exportCtkWh", serie(4.2, 0.6)}, {"includes", QVariantList{"energy", "grid", "taxes"}},
{"source", "tempo"}, {"knownUntil", t0.addDays(1).toString(Qt::ISODate)}}}});
QVariantList charges = req.value("loads").toList();
for (int k = 0; charges.count() < 4; ++k) {
QVariantMap c = charges.at(k).toMap();
c.insert("id", QUuid::createUuid().toString());
charges << c;
}
req.insert("loads", charges);
QVariantList executees, actionsRegles;
for (const QVariant &c : charges) {
const QString id = c.toMap().value("id").toString();
executees << QVariantMap{{"loadId", id}, {"kind", "setpoint"}, {"powerW", 1234.5}, {"clamped", false}};
actionsRegles << QVariantMap{{"loadId", id}, {"kind", "setpoint"}, {"powerW", 1234.5},
{"reasonCode", "SURPLUS_SETPOINT"},
{"reasonParams", QVariantMap{{"budgetW", 3000}, {"setpointW", 1234.5}}}};
}
QVariantList creneaux;
for (int i = 0; i < 48; ++i) {
QVariantList actions;
for (const QVariant &c : charges)
actions << QVariantMap{{"loadId", c.toMap().value("id")}, {"kind", "setpoint"}, {"powerW", 1500},
{"reasonCode", "SURPLUS_SETPOINT"}, {"reason", "surplus prévu sur ce créneau"}};
creneaux << QVariantMap{{"from", t0.addSecs(3600 * i).toString(Qt::ISODate)},
{"to", t0.addSecs(3600 * (i + 1)).toString(Qt::ISODate)},
{"actions", actions}, {"expected", QVariantMap{{"gridImportW", 120}, {"gridExportW", 0}}}};
}
auto taille = [](const QVariantMap &l) {
return QJsonDocument(QJsonObject::fromVariantMap(l)).toJson(QJsonDocument::Compact).size() + 1;
};
const QVariantMap regles{{"t", OptimizerTrace::horodatage(t0)}, {"request", req},
{"response", QVariantMap{{"result", QVariantMap{
{"strategy", "rules"}, {"computedAt", t0.toString(Qt::ISODate)},
{"validUntil", t0.addSecs(60).toString(Qt::ISODate)},
{"slots", QVariantList{QVariantMap{{"from", t0.toString(Qt::ISODate)},
{"to", t0.addSecs(60).toString(Qt::ISODate)},
{"actions", actionsRegles}}}}}}}},
{"executed", executees}, {"strategy", "rules"}};
QVariantMap optimiseur = regles;
optimiseur.insert("response", QVariantMap{{"result", QVariantMap{
{"planId", QUuid::createUuid().toString()}, {"strategy", "etm-optimizer"},
{"validUntil", t0.addSecs(1800).toString(Qt::ISODate)}, {"slots", creneaux},
{"diagnostics", QVariantMap{{"status", "optimal"}, {"computeMs", 412}, {"solver", "highs"}}}}}});
optimiseur.insert("strategy", "optimizer");
optimiseur.insert("latencyMs", 431);
const int sansPrevision = taille(QVariantMap{{"request", m_requetes.first()}});
const int r = taille(regles), o = taille(optimiseur);
qInfo().noquote() << QStringLiteral("[mesure] requête réelle du banc (2 charges, sans prévision) : %1 o").arg(sansPrevision);
qInfo().noquote() << QStringLiteral("[mesure] ligne rule-based, 4 charges, prévision 48 créneaux : %1 o — %2 o/jour à 1 cycle/min")
.arg(r).arg(qint64(r) * 1440);
qInfo().noquote() << QStringLiteral("[mesure] ligne optimiseur, plan de 48 créneaux × 4 actions : %1 o — %2 o/jour")
.arg(o).arg(qint64(o) * 1440);
// Le plafond par défaut doit tenir une journée PLEINE d'optimiseur au rythme mesuré au banc
// (1 cycle/min), avec 15 % de marge pour les cycles déclenchés par un événement ; et une
// journée rule-based quatre fois.
QVERIFY2(qint64(o) * 1440 * 115 / 100 <= OptimizerTrace::PlafondDefautOctets,
qPrintable(QStringLiteral("%1 o par ligne d'optimiseur").arg(o)));
QVERIFY2(qint64(r) * 1440 * 4 <= OptimizerTrace::PlafondDefautOctets,
qPrintable(QStringLiteral("%1 o par ligne rule-based").arg(r)));
}
void TestSocketScheduler::testTracerPairsTheResponseByRequestId()
{
// Le traceur seul : une réponse ne complète QUE la ligne de SA requête. En service, le
// SocketScheduler écarte déjà les réponses obsolètes ; cette garde est la seconde.
const QString d = m_dossierTraces.path() + "/appariement";
QDir().mkpath(d);
OptimizerTrace::Reglage r;
r.active = true;
r.dossier = d;
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
{
OptimizerTracer tr(r);
tr.noterCycle({{"t", "a"}, {"strategy", "rules"}}, t0, 7);
tr.noterReponse(6, {{"result", QVariantMap{{"faux", true}}}}, 1); // une autre requête
tr.noterReponse(0, {{"result", QVariantMap{{"faux", true}}}}, 1); // aucune requête
tr.noterReponse(7, {{"result", QVariantMap{{"bon", true}}}}, 12);
tr.noterReponse(7, {{"result", QVariantMap{{"double", true}}}}, 13); // déjà complétée
} // le destructeur vide la file
const QList<QVariantMap> l = traces(d, 1);
QCOMPARE(l.count(), 1);
QCOMPARE(l.first().value("response").toMap(), (QVariantMap{{"result", QVariantMap{{"bon", true}}}}));
QCOMPARE(l.first().value("latencyMs").toInt(), 12);
}
QTEST_MAIN(TestSocketScheduler)
#include "testsocketscheduler.moc"