test(simulation) : le refus de SetLoadConfig est publié par RPC, cause par cause

testSetLoadConfigRefusalIsPublished : l'introspection déclare o:fault et
o:faultParams à côté d'energyError ; un succès rend toujours energyError
seul ; chacune des vingt causes atteignables rend EnergyErrorInvalidParameter,
fault LOAD_CONFIG_REFUSED et les faultParams attendus, sans rien changer à
GetLoadConfig ni au fichier, octet pour octet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-10-22 20:23:28 +02:00
parent 53a10b3062
commit f7aa370acb
2 changed files with 146 additions and 0 deletions

View File

@ -59,6 +59,9 @@ using namespace nymeaserver;
#include <QDir> #include <QDir>
#include <QFile> #include <QFile>
#include <QFileInfo> #include <QFileInfo>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QHash> #include <QHash>
#include <QScopeGuard> #include <QScopeGuard>
@ -6108,6 +6111,145 @@ void Simulation::testDeletedEvChargerConfigComesBack()
#endif #endif
} }
void Simulation::testSetLoadConfigRefusalIsPublished()
{
#ifndef ETM_ARBITRATOR
QSKIP("testSetLoadConfigRefusalIsPublished nécessite ETM_ARBITRATOR.");
#else
// [Q7, suite] Même forme que SetTariff : EnergyErrorInvalidParameter INCHANGÉ, et à côté un
// code et un endroit. Avant, l'app recevait le code d'erreur seul, pour tous les refus.
// Identifiants inventés, de la forme de ceux d'une installation.
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-refus.json";
QFile::remove(cfgPath);
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
cleanupTestCase();
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
initTestCase();
const auto nettoyer = qScopeGuard([&] {
qunsetenv("NYMEA_ENERGY_LOAD_CONFIG");
QFile::remove(cfgPath);
});
// Le contrat PUBLIÉ : les deux champs sont déclarés, OPTIONNELS ; energyError reste requis.
const QVariantMap retours = injectAndWait("JSONRPC.Introspect").toMap().value("params").toMap()
.value("methods").toMap().value("NymeaEnergy.SetLoadConfig").toMap()
.value("returns").toMap();
QStringList cles = retours.keys();
cles.sort();
QCOMPARE(cles, (QStringList{"energyError", "o:fault", "o:faultParams"}));
auto poser = [&](const QVariantList &l) {
return injectAndWait("NymeaEnergy.SetLoadConfig", {{"loadConfigs", l}}).toMap();
};
auto lire = [&] {
return QJsonDocument::fromVariant(injectAndWait("NymeaEnergy.GetLoadConfig").toMap()
.value("params").toMap().value("loadConfigs"))
.toJson(QJsonDocument::Compact);
};
auto fichier = [&] {
QFile f(cfgPath);
return f.open(QIODevice::ReadOnly) ? f.readAll() : QByteArray();
};
auto json = [](const QVariant &v) { return QJsonDocument::fromVariant(v).toJson(QJsonDocument::Compact); };
const QString relaisA = QStringLiteral("{aaaaaaaa-0000-4000-8000-0000000000a1}");
const QString relaisC = QStringLiteral("{aaaaaaaa-0000-4000-8000-0000000000c3}");
const QString routeur = QStringLiteral("{bbbbbbbb-0000-4000-8000-0000000000b2}");
const QVariantMap ballon{{"id", "ballon-a"}, {"label", "Ballon"}, {"adapter", "relay-router"},
{"mode", "fixed"}, {"priority", 1}, {"enabled", true}, {"minOnS", 0}, {"minOffS", 0},
{"relays", QVariantList{QVariantMap{{"thingId", relaisA}, {"powerW", 1000}}}}};
const QVariantMap routeurPv{{"id", routeur}, {"label", "Routeur"}, {"adapter", "etmvariableload"},
{"mode", "dynamic"}, {"maxPowerW", 3000}, {"priority", 2}, {"enabled", true}};
const QVariantList base{ballon, routeurPv};
auto avec = [&](int i, const QVariantMap &modifs) {
QVariantList l = base;
QVariantMap m = l.at(i).toMap();
for (auto it = modifs.constBegin(); it != modifs.constEnd(); ++it)
m.insert(it.key(), it.value());
l[i] = m;
return l;
};
auto pac = [&](const QVariantList &etats) {
return QVariantMap{{"id", "pac-c"}, {"label", "PAC"}, {"adapter", "sg-ready"}, {"mode", "fixed"},
{"priority", 3}, {"enabled", true},
{"sgReady", QVariantMap{{"states", etats}, {"minStateHoldS", 0}}}};
};
auto etat = [&](int n) {
return QVariantMap{{"state", n}, {"relays", QVariantList{relaisC}}, {"estimatedPowerW", 1500}};
};
QVariantMap ballonC = ballon;
ballonC.insert("id", "ballon-c");
ballonC.insert("priority", 3);
ballonC.insert("relays", QVariantList{QVariantMap{{"thingId", relaisA}, {"powerW", 500}}});
// Le point de départ : accepté, et la réponse d'un SUCCÈS est exactement celle d'avant.
QVariantMap r = poser(base);
QCOMPARE(r.value("status").toString(), QStringLiteral("success"));
QCOMPARE(r.value("params").toMap().keys(), QStringList{"energyError"});
QCOMPARE(r.value("params").toMap().value("energyError").toString(), QStringLiteral("EnergyErrorNoError"));
const QByteArray avantGet = lire();
const QByteArray avantDisque = fichier();
QVERIFY(!avantDisque.isEmpty());
struct Cas { const char *quoi; QVariantList charges; QVariantMap attendu; bool disque; };
const QList<Cas> cas{
{"ID trop long", avec(0, {{"id", QString(129, QLatin1Char('x'))}}),
{{"cause", "ID"}, {"index", 0}, {"length", 129}, {"max", 128}}, false},
{"ID hors jeu", avec(0, {{"id", "ballon a"}}),
{{"cause", "ID"}, {"index", 0}, {"length", 8}, {"max", 128}, {"position", 6}}, false},
{"ID vide", avec(1, {{"id", ""}}), {{"cause", "ID"}, {"index", 1}, {"length", 0}, {"max", 128}}, false},
{"LABEL", avec(0, {{"label", QString(257, QLatin1Char('L'))}}),
{{"cause", "LABEL"}, {"index", 0}, {"id", "ballon-a"}, {"length", 257}, {"max", 256}}, false},
{"DEADLINE", avec(0, {{"needs", QVariantMap{{"dailyDeadline", "12h00"}}}}),
{{"cause", "DEADLINE"}, {"index", 0}, {"id", "ballon-a"}}, false},
{"MODE", avec(0, {{"mode", "variable"}}), {{"cause", "MODE"}, {"index", 0}, {"id", "ballon-a"}}, false},
{"DOMAIN", avec(0, {{"domain", "garage"}}), {{"cause", "DOMAIN"}, {"index", 0}, {"id", "ballon-a"}}, false},
{"RANK_ORIGIN", avec(0, {{"rankOrigin", "robot"}}),
{{"cause", "RANK_ORIGIN"}, {"index", 0}, {"id", "ballon-a"}}, false},
{"RANK_ORIGIN_AUTO", avec(1, {{"rankOrigin", "auto"}}),
{{"cause", "RANK_ORIGIN_AUTO"}, {"index", 1}, {"id", routeur}}, false},
{"ADAPTER", avec(0, {{"adapter", "pompe"}}), {{"cause", "ADAPTER"}, {"index", 0}, {"id", "ballon-a"}}, false},
{"FIELD_FORBIDDEN", avec(0, {{"minPowerW", 100}}),
{{"cause", "FIELD_FORBIDDEN"}, {"index", 0}, {"id", "ballon-a"}, {"field", "minPowerW"}}, false},
{"FIELD_REQUIRED", avec(1, {{"maxPowerW", 0}}),
{{"cause", "FIELD_REQUIRED"}, {"index", 1}, {"id", routeur}, {"field", "maxPowerW"}}, false},
{"FIELD_RANGE", avec(0, {{"relays", QVariantList{QVariantMap{{"thingId", relaisA}, {"powerW", 0}}}}}),
{{"cause", "FIELD_RANGE"}, {"index", 0}, {"id", "ballon-a"}, {"field", "relays.powerW"}}, false},
{"FIELD_DUPLICATE", avec(0, {{"relays", QVariantList{QVariantMap{{"thingId", relaisA}, {"powerW", 1000}},
QVariantMap{{"thingId", relaisA}, {"powerW", 500}}}}}),
{{"cause", "FIELD_DUPLICATE"}, {"index", 0}, {"id", "ballon-a"}, {"field", "relays.thingId"}}, false},
{"FIELD_DUPLICATE état", QVariantList(base) << pac({etat(2), etat(3), etat(3)}),
{{"cause", "FIELD_DUPLICATE"}, {"index", 2}, {"id", "pac-c"}, {"field", "sgReady.states.state"},
{"state", 3}}, false},
{"SG_READY_STATE_2", QVariantList(base) << pac({etat(3)}),
{{"cause", "SG_READY_STATE_2"}, {"index", 2}, {"id", "pac-c"}}, false},
{"ID_DUPLICATE", avec(1, {{"id", "ballon-a"}}), {{"cause", "ID_DUPLICATE"}, {"index", 1}, {"id", "ballon-a"}}, false},
{"THING_SHARED", QVariantList(base) << ballonC,
{{"cause", "THING_SHARED"}, {"indexes", QVariantList{0, 2}}, {"ids", QVariantList{"ballon-a", "ballon-c"}}}, false},
{"RANK_DUPLICATE", avec(1, {{"priority", 1}}),
{{"cause", "RANK_DUPLICATE"}, {"indexes", QVariantList{0, 1}}, {"ids", QVariantList{"ballon-a", routeur}},
{"rank", 1}}, false},
// Une charge utile VALIDE, mais le stockage est inaccessible.
{"WRITE_FAILED", avec(0, {{"label", "Ballon du bas"}}), {{"cause", "WRITE_FAILED"}}, true},
};
for (const Cas &c : cas) {
if (c.disque)
qputenv("NYMEA_ENERGY_LOAD_CONFIG", "/q7-repertoire-absent/energy-load-configuration.json");
r = poser(c.charges);
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
QVERIFY2(r.value("status").toString() == QLatin1String("success"), c.quoi); // c'est le MOTEUR qui refuse
const QVariantMap p = r.value("params").toMap();
QVERIFY2(p.value("energyError").toString() == QLatin1String("EnergyErrorInvalidParameter"), c.quoi);
QVERIFY2(p.value("fault").toString() == QLatin1String(DecisionCode::LoadConfigRefused), c.quoi);
QVERIFY2(json(p.value("faultParams")) == json(c.attendu),
qPrintable(QStringLiteral("%1 : %2").arg(c.quoi, QString::fromUtf8(json(p.value("faultParams"))))));
// RIEN n'a bougé : ni ce que l'app relit, ni le fichier, octet pour octet.
QVERIFY2(lire() == avantGet, c.quoi);
QVERIFY2(fichier() == avantDisque, c.quoi);
}
#endif
}
void Simulation::testVanishedRelayFreezesTheLoad() void Simulation::testVanishedRelayFreezesTheLoad()
{ {
#ifndef ETM_ARBITRATOR #ifndef ETM_ARBITRATOR

View File

@ -250,6 +250,10 @@ private slots:
// réglable — le trou de 3g-2 rouvert par le côté client. // réglable — le trou de 3g-2 rouvert par le côté client.
void testDeletedEvChargerConfigComesBack(); void testDeletedEvChargerConfigComesBack();
// [Q7, suite] Le refus de SetLoadConfig PUBLIE sa cause (fault LOAD_CONFIG_REFUSED et
// faultParams), déclarée dans le schéma, sans rien modifier — mémoire, GetLoadConfig, disque.
void testSetLoadConfigRefusalIsPublished();
// [ECS-111] Un relais DÉCLARÉ qui disparaît du registre nymea : la charge sort de // [ECS-111] Un relais DÉCLARÉ qui disparaît du registre nymea : la charge sort de
// l'arbitrage (available faux, THING_MISSING) et reste dans la comptabilité, figée à la // l'arbitrage (available faux, THING_MISSING) et reste dans la comptabilité, figée à la
// puissance crue engagée. Avant, elle publiait le nominal du palier entier — part du // puissance crue engagée. Avant, elle publiait le nominal du palier entier — part du