diff --git a/energyplugin/etm/config/loadconfigstore.cpp b/energyplugin/etm/config/loadconfigstore.cpp index 6fb71a0..63bd498 100644 --- a/energyplugin/etm/config/loadconfigstore.cpp +++ b/energyplugin/etm/config/loadconfigstore.cpp @@ -430,8 +430,14 @@ bool LoadConfigStore::commit(const LoadConfigs &configs, int premierBorne, QStri const LoadConfigs previous = m_configs; m_configs = configs; - if (!save(error)) { + QString echec; + if (!save(&echec)) { m_configs = previous; // rollback mémoire si l'écriture échoue + // La cause système est dite ICI : le refus de SetLoadConfig ne journalise que son motif, + // et WRITE_FAILED ne dit pas pourquoi le disque a refusé. Aucune saisie n'y entre. + qCWarning(dcNymeaEnergy()) << "[LoadConfigStore] Écriture de la configuration échouée :" + << filePath() << echec; + if (error) *error = echec; if (fault) *fault = QVariantMap{{QStringLiteral("cause"), QStringLiteral("WRITE_FAILED")}}; return false; diff --git a/energyplugin/nymeaenergyjsonhandler.cpp b/energyplugin/nymeaenergyjsonhandler.cpp index 1cb0bc5..f4d853a 100644 --- a/energyplugin/nymeaenergyjsonhandler.cpp +++ b/energyplugin/nymeaenergyjsonhandler.cpp @@ -1150,45 +1150,44 @@ JsonReply *NymeaEnergyJsonHandler::GetLoadConfig(const QVariantMap ¶ms) JsonReply *NymeaEnergyJsonHandler::SetLoadConfig(const QVariantMap ¶ms) { // Même forme que SetTariff : le code d'erreur amont, inchangé, et à côté le MOTIF publiable — - // une cause et un endroit, jamais une phrase (règle 12). Le journal garde la phrase - // française rendue depuis ce motif, suivie du détail du refus. - auto refuser = [this](const QVariantMap &faultParams, const QString &detail) { + // une cause et un endroit, jamais une phrase (règle 12). Le journal ne porte que la phrase + // française rendue depuis ce motif, comme pour SetTariff : la phrase détaillée du refus + // recopie ce que le client a saisi (mode, domaine, relais, libellé…), et un saut de ligne y + // fabriquerait une fausse ligne de journal (relecture q7-suite, I-1). + auto refuser = [this](const QVariantMap &faultParams) { // 7-d — le refus au moins aussi haut que l'enregistrement. qCWarning(dcNymeaEnergy()).noquote() - << QStringLiteral("[NymeaEnergy] SetLoadConfig rejetée : %1 (motif %2)%3") + << QStringLiteral("[NymeaEnergy] SetLoadConfig rejetée : %1 (motif %2)") .arg(renderFr({DecisionCode::LoadConfigRefused, faultParams}), - QLatin1String(DecisionCode::LoadConfigRefused), - detail.isEmpty() ? QString() : QStringLiteral(" — ") + detail); + QLatin1String(DecisionCode::LoadConfigRefused)); return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorInvalidParameter)}, {"fault", QLatin1String(DecisionCode::LoadConfigRefused)}, {"faultParams", faultParams}}); }; if (!m_loadConfigStore) - return refuser({{QStringLiteral("cause"), QStringLiteral("STORE_UNAVAILABLE")}}, - QStringLiteral("aucun store configuré")); + return refuser({{QStringLiteral("cause"), QStringLiteral("STORE_UNAVAILABLE")}}); LoadConfigs configs; foreach (const QVariant &item, params.value("loadConfigs").toList()) configs.append(LoadConfig::fromMap(item.toMap())); - QString why; QVariantMap faultParams; - // Q7 — les bornes de saisie AVANT tout le reste, réconciliation comprise : ses motifs citent - // l'identifiant et le libellé, qui doivent donc être bornés avant d'entrer au journal. - if (!LoadConfigStore::validateBounds(configs, &why, &faultParams)) - return refuser(faultParams, why); + // Q7 — les bornes de saisie AVANT tout le reste, réconciliation comprise : le motif ne cite + // un identifiant que bien formé. + if (!LoadConfigStore::validateBounds(configs, nullptr, &faultParams)) + return refuser(faultParams); // LM-1209-c — la marque de rang se réconcilie ICI, à la frontière CLIENTE, et nulle part // ailleurs : c'est la seule couche qui sache qu'une écriture vient du dehors. Le moteur, // lui, pose légitimement "auto" en créant une entrée d'office. LoadConfigs reconciliees; - if (!m_loadConfigStore->reconcileRankOrigin(configs, &reconciliees, &why, &faultParams)) - return refuser(faultParams, why); + if (!m_loadConfigStore->reconcileRankOrigin(configs, &reconciliees, nullptr, &faultParams)) + return refuser(faultParams); configs = reconciliees; - if (!m_loadConfigStore->setConfigs(configs, &why, &faultParams)) - return refuser(faultParams, why); + if (!m_loadConfigStore->setConfigs(configs, nullptr, &faultParams)) + return refuser(faultParams); // store.setConfigs() a déjà persisté + émis changed() → l'arbitre reconstruit, la notif part. return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorNoError)}}); } diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index 66a93fc..220d9ce 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -147,6 +147,8 @@ public: bool contains(const QString &fragment) const { return m_texte.contains(fragment); } bool contient(QtMsgType type) const { return m_types.contains(type); } QString texte() const { return m_texte; } + //! Les messages un par un : une fausse ligne de journal ne se voit qu'À L'INTÉRIEUR d'un message. + QStringList messages() const { return m_messages; } private: static void collecte(QtMsgType type, const QMessageLogContext &ctx, const QString &msg) @@ -154,12 +156,14 @@ private: // Toutes les captures vivantes reçoivent le message — c'est ce que le chaînage visait. for (JournalCapture *c : s_pile) { c->m_texte += msg + QLatin1Char('\n'); + c->m_messages.append(msg); c->m_types.insert(type); } if (s_origine) s_origine(type, ctx, msg); // le message reste visible dans la sortie de test } QString m_texte; + QStringList m_messages; QSet m_types; static QList s_pile; static QtMessageHandler s_origine; @@ -6247,6 +6251,46 @@ void Simulation::testSetLoadConfigRefusalIsPublished() QVERIFY2(lire() == avantGet, c.quoi); QVERIFY2(fichier() == avantDisque, c.quoi); } + + // [relecture q7-suite, I-1] Le journal d'un refus ne recopie RIEN de la saisie. La phrase + // détaillée y partait sans échappement : un saut de ligne glissé dans un champ fabriquait une + // fausse ligne de journal. Chaque champ qu'elle citait, refusé tour à tour. + const QString forge = QStringLiteral("x\n[NymeaEnergy] FAUX"); + QVariantMap ballonF = ballon; + ballonF.insert("relays", QVariantList{QVariantMap{{"thingId", forge}, {"powerW", 1000}}}); + QVariantMap ballonCF = ballonC; + ballonCF.insert("relays", QVariantList{QVariantMap{{"thingId", forge}, {"powerW", 500}}}); + struct Forge { const char *quoi; QVariantList charges; const char *cause; }; + const QList forges{ + {"mode", avec(0, {{"mode", forge}}), "MODE"}, + {"domain", avec(0, {{"domain", forge}}), "DOMAIN"}, + {"rankOrigin", avec(0, {{"rankOrigin", forge}}), "RANK_ORIGIN"}, + {"adapter", avec(0, {{"adapter", forge}}), "ADAPTER"}, + {"relays[].thingId", avec(0, {{"relays", QVariantList{QVariantMap{{"thingId", forge}, {"powerW", 1000}}, + QVariantMap{{"thingId", forge}, {"powerW", 500}}}}}), + "FIELD_DUPLICATE"}, + {"dailyDeadline", avec(0, {{"needs", QVariantMap{{"dailyDeadline", forge}}}}), "DEADLINE"}, + {"label (Config … invalide)", avec(0, {{"label", forge}, {"mode", "variable"}}), "MODE"}, + {"label (rang en double)", avec(1, {{"label", forge}, {"priority", 1}}), "RANK_DUPLICATE"}, + {"label (auto fabriqué)", avec(1, {{"label", forge}, {"rankOrigin", "auto"}}), "RANK_ORIGIN_AUTO"}, + {"relais partagé", QVariantList{ballonF, routeurPv, ballonCF}, "THING_SHARED"}, + }; + for (const Forge &f : forges) { + JournalCapture journal; + const QVariantMap p = poser(f.charges).value("params").toMap(); + QVERIFY2(p.value("faultParams").toMap().value("cause").toString() == QLatin1String(f.cause), + qPrintable(QStringLiteral("%1 : %2").arg(f.quoi, QString::fromUtf8(json(p.value("faultParams")))))); + // Non vide : le refus EST journalisé… + QVERIFY2(journal.contains(QStringLiteral("SetLoadConfig rejetée")), f.quoi); + // …et aucun message n'y ajoute une ligne qui se fasse passer pour un message du moteur. + for (const QString &m : journal.messages()) { + const QStringList lignes = m.split(QLatin1Char('\n')); + for (int i = 1; i < lignes.count(); ++i) + QVERIFY2(!lignes.at(i).startsWith(QLatin1String("[NymeaEnergy]"), Qt::CaseInsensitive), + qPrintable(QStringLiteral("%1 : fausse ligne de journal « %2 »").arg(f.quoi, lignes.at(i)))); + } + QVERIFY2(lire() == avantGet, f.quoi); + } #endif }