From f7aa370acb68dd1b15632e90c3e4e8e774b40352 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Thu, 22 Oct 2026 20:23:28 +0200 Subject: [PATCH] =?UTF-8?q?test(simulation)=20:=20le=20refus=20de=20SetLoa?= =?UTF-8?q?dConfig=20est=20publi=C3=A9=20par=20RPC,=20cause=20par=20cause?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- tests/auto/simulation/simulation.cpp | 142 +++++++++++++++++++++++++++ tests/auto/simulation/simulation.h | 4 + 2 files changed, 146 insertions(+) diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index d4f4002..7e32c9b 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -59,6 +59,9 @@ using namespace nymeaserver; #include #include #include +#include +#include +#include #include #include @@ -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{ + {"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 diff --git a/tests/auto/simulation/simulation.h b/tests/auto/simulation/simulation.h index 1145cfc..12782e4 100644 --- a/tests/auto/simulation/simulation.h +++ b/tests/auto/simulation/simulation.h @@ -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