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:
parent
53a10b3062
commit
f7aa370acb
@ -59,6 +59,9 @@ using namespace nymeaserver;
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
|
||||
#include <QHash>
|
||||
#include <QScopeGuard>
|
||||
@ -6108,6 +6111,145 @@ void Simulation::testDeletedEvChargerConfigComesBack()
|
||||
#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()
|
||||
{
|
||||
#ifndef ETM_ARBITRATOR
|
||||
|
||||
@ -250,6 +250,10 @@ private slots:
|
||||
// réglable — le trou de 3g-2 rouvert par le côté client.
|
||||
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
|
||||
// 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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user