Patrick Schurig 1f6c7ec27a refactor(plan): le plan rule-based porte strategy « rules », comme activeStrategy (brief 4 T6)
Décision de Patrick du 2026-10-17 : un seul vocabulaire. Plan::strategy du rule-based,
GetPlan.strategy et la doc passent de « rule-based » à « rules » (INTERFACE.md, protocole §6).
Les libellés de journal (« repli rule-based ») ne sont pas des valeurs et restent.

Référence decisions régénérée. Preuve AVANT régénération, moteur de e592618 + ce seul
renommage, contre l'ancienne référence : comparateur --strategie (nouvelle option, qui ne tolère
que plan.strategy rule-based → rules) IDENTIQUE HORS STRATÉGIE — 23 792 lignes, 23 728
identiques à l'octet, 64 qui ne diffèrent que par .plan.strategy (décompte indépendant des
chemins JSON : 64 × .plan.strategy "rule-based" → "rules", rien d'autre). 0 rejeu. Le même
build, relu contre la nouvelle référence : IDENTIQUE ; 6 suites vertes.

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

1391 lines
72 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/types/decisionreason.h"
#include <QFileInfo>
#include <QJsonDocument>
#include <QLoggingCategory>
#include <QSignalSpy>
#include <QTemporaryDir>
#include <experiences/experiencemanager.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();
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);
};
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());
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();
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);
}
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"));
}
QTEST_MAIN(TestSocketScheduler)
#include "testsocketscheduler.moc"