test(socketscheduler) : ce qu'une configuration héritée hors règle envoie dans GetPlan

testInheritedOutOfRuleConfigWhatGetPlanSends : une configuration écrite sur
disque avant la règle (identifiant « ballon test », échéances « 07:00 » et
« 7:00 ») est chargée par un store neuf et signalée. Lu sur le faux
optimiseur : l'identifiant hors règle part tel quel ; l'échéance lisible part
avec ses energyTargets (contre-épreuve) ; l'échéance illisible ne part ni
brute ni résolue, minEnergyWhPerDay part encore. La requête est jugée
conforme au contrat embarqué (LoadNeeds.dailyDeadline impose
^[0-9]{2}:[0-9]{2}$, que « 7:00 » violait).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-10-22 20:26:17 +02:00
parent a728a3c8da
commit 8988af0a16

View File

@ -13,6 +13,7 @@
#include "energysettings.h"
#include "etm/energyarbitrator.h"
#include "etm/config/loadconfigstore.h"
#include "etm/scheduler/socketscheduler.h"
#include "etm/scheduler/optimizerstatus.h"
#include "etm/scheduler/publishedplan.h"
@ -24,6 +25,7 @@
#include <QJsonArray>
#include <QJsonDocument>
#include <QLoggingCategory>
#include <QRegularExpression>
#include <QSignalSpy>
#include <QTemporaryDir>
@ -127,6 +129,8 @@ private slots:
void testUpdateLearnedIsLoggedOnceAndIgnored();
// Protocole 1.2, Q7 — une requête trop longue n'est pas envoyée, et l'échec compte
void testARequestTooLongCountsAsAFailure();
// Q7, suite — une configuration héritée hors règle, et ce qui part réellement dans GetPlan
void testInheritedOutOfRuleConfigWhatGetPlanSends();
// Brief 4 T6 — traces de décision (annexe D)
void testTraceIsOffByDefault();
void testTraceSettingByRpc();
@ -182,6 +186,9 @@ private:
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);
//! Q7, suite : remplace la configuration par \p charges ÉCRITES SUR DISQUE et relues par un
//! store neuf — le chemin d'une configuration héritée, que SetLoadConfig refuserait.
void chargerConfigHeritee(const QVariantList &charges);
//! 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
@ -1565,6 +1572,63 @@ QList<QVariantMap> TestSocketScheduler::traces(const QString &dossier, int n)
return lignes;
}
void TestSocketScheduler::chargerConfigHeritee(const QVariantList &charges)
{
QFile f(qEnvironmentVariable("NYMEA_ENERGY_LOAD_CONFIG"));
QVERIFY(f.open(QIODevice::WriteOnly | QIODevice::Text));
f.write(QJsonDocument(QJsonObject{{"version", 1}, {"loads", QJsonArray::fromVariantList(charges)}}).toJson());
f.close();
m_arb->setLoadConfigStore(new LoadConfigStore(m_arb));
}
void TestSocketScheduler::testInheritedOutOfRuleConfigWhatGetPlanSends()
{
// Q7, suite : une configuration ENREGISTRÉE avant la règle est chargée, signalée — et voici ce
// qui en part réellement vers l'optimiseur, lu sur le faux optimiseur. Un identifiant hors
// règle part tel quel (c'est une clé : le changer serait retirer une charge) ; une échéance
// illisible NE PART PAS — le contrat (LoadNeeds.dailyDeadline, motif ^[0-9]{2}:[0-9]{2}$)
// l'aurait de toute façon déclarée non conforme.
monter();
m_repondre = [](const QVariantMap &) { return QVariantMap{{"abstain", true}, {"reason", "test"}}; };
QTest::ignoreMessage(QtWarningMsg, QRegularExpression(
QStringLiteral("IDENTIFIANT HORS RÈGLE : charge \"ballon test\"")));
QTest::ignoreMessage(QtWarningMsg, QRegularExpression(
QStringLiteral("ÉCHÉANCE ILLISIBLE : charge \"%1\", needs.dailyDeadline \"7:00\"")
.arg(QRegularExpression::escape(m_var))));
chargerConfigHeritee({
QVariantMap{{"id", "ballon test"}, {"label", "Ballon"}, {"adapter", "relay-router"}, {"mode", "fixed"},
{"priority", 1}, {"enabled", true}, {"minOnS", 0}, {"minOffS", 0},
{"relays", QVariantList{QVariantMap{{"thingId", m_relais.toString()}, {"powerW", 1000}}}},
{"needs", QVariantMap{{"dailyDeadline", "07:00"}, {"minEnergyWhPerDay", 1000}}}},
QVariantMap{{"id", m_var}, {"label", "Routeur"}, {"adapter", "etmvariableload"}, {"mode", "dynamic"},
{"maxPowerW", 3000}, {"priority", 2}, {"enabled", true},
{"needs", QVariantMap{{"dailyDeadline", "7:00"}, {"minEnergyWhPerDay", 2000}}}}});
cycle(utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)), -3000);
QVERIFY(!m_requetes.isEmpty());
QHash<QString, QVariantMap> charges;
for (const QVariant &v : m_requetes.last().value("loads").toList())
charges.insert(v.toMap().value("id").toString(), v.toMap());
// L'identifiant hors règle part TEL QUEL, et l'échéance lisible avec lui (contre-épreuve) :
// « 07:00 » est envoyée, et l'obligation a son échéance absolue.
QVERIFY2(charges.contains("ballon test"), qUtf8Printable(charges.keys().join(", ")));
const QVariantMap besoinsLisibles = charges.value("ballon test").value("needs").toMap();
QCOMPARE(besoinsLisibles.value("dailyDeadline").toString(), QStringLiteral("07:00"));
QVERIFY(!besoinsLisibles.value("energyTargets").toList().isEmpty());
// L'échéance illisible est ABSENTE : ni brute, ni résolue. L'obligation en énergie part
// encore — sans échéance, elle n'a pas de cadence, et rien n'est acheté pour elle.
QVERIFY(charges.contains(m_var));
const QVariantMap besoins = charges.value(m_var).value("needs").toMap();
QVERIFY2(!besoins.contains("dailyDeadline"), qUtf8Printable(QString::fromUtf8(QJsonDocument::fromVariant(besoins).toJson())));
QVERIFY(!besoins.contains("energyTargets"));
QCOMPARE(besoins.value("minEnergyWhPerDay").toInt(), 2000);
// Et ce qui est parti est CONFORME au contrat publié.
jugerLesRequetesDuMoteur();
}
void TestSocketScheduler::testARequestTooLongCountsAsAFailure()
{
// Protocole 1.2, Q7 : une requête que le client n'émet pas (ligne de plus de 1 Mio) est un