diff --git a/docs/OPTIMIZER_PROTOCOL.md b/docs/OPTIMIZER_PROTOCOL.md index ac27fdc..3969935 100644 --- a/docs/OPTIMIZER_PROTOCOL.md +++ b/docs/OPTIMIZER_PROTOCOL.md @@ -1409,7 +1409,10 @@ But : comparer des optimiseurs **sur les mêmes entrées** et rejouer un inciden "powerW":1200,"clamped":false}],"strategy":"optimizer","latencyMs":12} ``` -- `request` : les params `GetPlan` envoyés (ou qui l'auraient été) ; `response` : ce qui a +- `request` : les params `GetPlan` envoyés (ou qui l'auraient été) — **ou, à leur place, + `requestBytes`**, la taille en octets de la ligne `GetPlan` quand elle dépasse 1 Mio (§2) : une + telle requête n'est pas envoyée (soumise au client, elle a pour réponse `request-too-long`), + elle n'est pas recopiée, et le rejeu passe la ligne ; `response` : ce qui a répondu à **cette** requête — `result` brut, ou `error.code` (`no-response` si elle a été remplacée sans réponse) ; `executed` : actions appliquées au cycle ; `latencyMs` : optimiseur seulement. La réponse à la requête N sert au cycle N+1 : la ligne N l'attend. diff --git a/energyplugin/etm/config/loadconfigstore.cpp b/energyplugin/etm/config/loadconfigstore.cpp index 6b9f2a0..82fbbf8 100644 --- a/energyplugin/etm/config/loadconfigstore.cpp +++ b/energyplugin/etm/config/loadconfigstore.cpp @@ -6,6 +6,7 @@ #include +#include #include #include #include @@ -13,6 +14,52 @@ #include #include #include +#include + +namespace { +//! L'identifiant tel qu'il entre dans un motif : entier s'il est bien formé (donc borné), sinon +//! ses 64 premiers caractères — une configuration héritée peut porter n'importe quoi. +QString idPourMotif(const QString &id) +{ + if (LoadConfigStore::idIsWellFormed(id) || id.toUcs4().size() <= 64) + return id; + return LoadConfigStore::leftChars(id, 64) + QStringLiteral("…"); +} + +//! Une valeur saisie citée dans un motif : assez pour la reconnaître, jamais en entier. +QString valeurPourMotif(const QString &valeur) +{ + return valeur.toUcs4().size() <= 16 ? valeur + : LoadConfigStore::leftChars(valeur, 16) + QStringLiteral("…"); +} + +//! Un libellé cité dans un motif : ses 64 premiers caractères. Celui d'une entrée héritée n'est +//! pas borné, et l'ajout automatique d'une borne journalise ce motif (relecture q7-suite, m-1). +QString libellePourMotif(const QString &libelle) +{ + return libelle.toUcs4().size() <= 64 ? libelle + : LoadConfigStore::leftChars(libelle, 64) + QStringLiteral("…"); +} + +//! Une valeur citée au journal, entre guillemets et ÉCHAPPÉE par Qt (\c "\n" pour un saut de +//! ligne) : une valeur persistée par un client ne fabrique pas de fausse ligne de journal. +QString citee(const QString &valeur) +{ + QString s; + QDebug(&s).nospace() << valeur; + return s; +} + +//! Le motif publiable d'un refus PAR ENTRÉE : la cause, la position, et l'identifiant quand il +//! est bien formé — un identifiant refusé ne se recopie pas. +QVariantMap motifEntree(const char *cause, int index, const QString &id) +{ + QVariantMap m{{QStringLiteral("cause"), QLatin1String(cause)}, {QStringLiteral("index"), index}}; + if (LoadConfigStore::idIsWellFormed(id)) + m.insert(QStringLiteral("id"), id); + return m; +} +} LoadConfigStore::LoadConfigStore(QObject *parent) : QObject{parent} @@ -91,31 +138,62 @@ bool LoadConfigStore::load() } // Q7 — même règle pour un libellé trop long : CHARGÉ TEL QUEL, jamais tronqué ni écarté. Le // client d'optimiseur refuse d'émettre un GetPlan de plus de 1 Mio ; c'est lui le filet. - { + // Chaque entrée est signalée, pas seulement la première : c'est la liste des réparations. + // Les valeurs persistées sont citées ÉCHAPPÉES (citee(), ou `<<` sans noquote()) : avant Q7, + // un client pouvait écrire un identifiant ou une échéance portant un saut de ligne. + for (int i = 0; i < loaded.count(); ++i) { QString pourquoi; - if (!validateLabels(loaded, &pourquoi)) - qCWarning(dcNymeaEnergy()).noquote() + if (!validateLabels(LoadConfigs(QList{loaded.at(i)}), &pourquoi)) + qCWarning(dcNymeaEnergy()) << "[LoadConfigStore] CONFIGURATION CONSERVÉE, LIBELLÉ TROP LONG :" << pourquoi - << "— chargé tel quel ; toute écriture sera refusée tant qu'il n'est pas raccourci."; + << "— chargé tel quel ; toute écriture cliente sera refusée tant qu'il n'est pas raccourci."; + if (!idIsWellFormed(loaded.at(i).id())) + qCWarning(dcNymeaEnergy()).noquote() + << QStringLiteral("[LoadConfigStore] CONFIGURATION CONSERVÉE, IDENTIFIANT HORS RÈGLE : " + "charge %1 (%2 caractères ; attendu 1 à %3 caractères " + "[A-Za-z0-9._{}-]) — chargée telle quelle ; toute écriture cliente " + "sera refusée tant qu'elle porte cet identifiant.") + .arg(citee(idPourMotif(loaded.at(i).id()))).arg(loaded.at(i).id().toUcs4().size()) + .arg(MaxIdChars); + // L'échéance, elle, est RETIRÉE de la mémoire — l'entrée reste. Une échéance illisible + // ne se résout en aucun instant ; la garder la ferait partir brute dans GetPlan, où + // l'optimiseur devrait deviner ce qu'elle veut dire. Sans elle, une obligation en énergie + // n'a plus de cadence et rien n'est acheté pour elle : le côté sûr. Le fichier n'est pas + // réécrit ici ; la première écriture cliente le remplacera. + const QString echeance = loaded.at(i).needs().dailyDeadline(); + if (!deadlineIsWellFormed(echeance)) { + qCWarning(dcNymeaEnergy()).noquote() + << QStringLiteral("[LoadConfigStore] CONFIGURATION CONSERVÉE, ÉCHÉANCE ILLISIBLE : " + "charge %1, needs.dailyDeadline %2 (attendu HH:MM, de 00:00 " + "à 23:59) — IGNORÉE : ni l'arbitre ni l'optimiseur ne la reçoivent, " + "l'obligation n'a plus d'échéance.") + .arg(citee(idPourMotif(loaded.at(i).id())), citee(valeurPourMotif(echeance))); + LoadConfigNeeds besoins = loaded.at(i).needs(); + besoins.setDailyDeadline(QString()); + loaded[i].setNeeds(besoins); + } } m_configs = loaded; return true; } -bool LoadConfigStore::validateSet(const LoadConfigs &configs, QString *error) +bool LoadConfigStore::validateSet(const LoadConfigs &configs, QString *error, QVariantMap *fault) { - auto fail = [error](const QString &why) { + auto fail = [error, fault](const QString &why, const QVariantMap &motif) { if (error) *error = why; + if (fault) *fault = motif; return false; }; QSet ids; - QHash proprietaire; // ThingId normalisé → libellé de la charge + QHash proprietaire; // ThingId normalisé → index de la charge - for (const LoadConfig &c : configs) { + for (int i = 0; i < configs.count(); ++i) { + const LoadConfig &c = configs.at(i); if (ids.contains(c.id())) - return fail(QStringLiteral("l'identifiant de charge \"%1\" apparaît deux fois").arg(c.id())); + return fail(QStringLiteral("l'identifiant de charge \"%1\" apparaît deux fois").arg(idPourMotif(c.id())), + motifEntree("ID_DUPLICATE", i, c.id())); ids.insert(c.id()); if (!c.enabled()) @@ -128,45 +206,57 @@ bool LoadConfigStore::validateSet(const LoadConfigs &configs, QString *error) // les autres — d'où ce contrôle d'ENSEMBLE, et d'où le fait qu'il soit distinct de // isValid(). for (const QString &thing : c.claimedThingIds()) { - const QString deja = proprietaire.value(thing); - if (!deja.isEmpty()) + if (proprietaire.contains(thing)) { + const int j = proprietaire.value(thing); return fail(QStringLiteral("le Thing %1 est commandé par deux charges actives, " "\"%2\" et \"%3\" : deux commandes contradictoires et " "une puissance comptée deux fois") - .arg(thing, deja, c.label())); - proprietaire.insert(thing, c.label()); + .arg(thing, libellePourMotif(configs.at(j).label()), libellePourMotif(c.label())), + QVariantMap{{QStringLiteral("cause"), QStringLiteral("THING_SHARED")}, + {QStringLiteral("indexes"), QVariantList{j, i}}, + {QStringLiteral("ids"), QVariantList{configs.at(j).id(), c.id()}}}); + } + proprietaire.insert(thing, i); } } return true; } -bool LoadConfigStore::validateRanks(const LoadConfigs &configs, QString *error) +bool LoadConfigStore::validateRanks(const LoadConfigs &configs, QString *error, QVariantMap *fault) { - QHash parRang; - for (const LoadConfig &c : configs) { - const QString deja = parRang.value(c.priority()); - if (!deja.isEmpty()) { + QHash parRang; // rang → index de la première charge qui le porte + for (int i = 0; i < configs.count(); ++i) { + const LoadConfig &c = configs.at(i); + if (parRang.contains(c.priority())) { + const int j = parRang.value(c.priority()); if (error) *error = QStringLiteral("les charges \"%1\" et \"%2\" portent toutes deux le rang " "%3 : leur ordre de service serait stable mais NON " "RÉGLABLE — il se déciderait sur leurs identifiants") - .arg(deja, c.label()).arg(c.priority()); + .arg(libellePourMotif(configs.at(j).label()), libellePourMotif(c.label())) + .arg(c.priority()); + if (fault) + *fault = QVariantMap{{QStringLiteral("cause"), QStringLiteral("RANK_DUPLICATE")}, + {QStringLiteral("indexes"), QVariantList{j, i}}, + {QStringLiteral("ids"), QVariantList{configs.at(j).id(), c.id()}}, + {QStringLiteral("rank"), c.priority()}}; return false; } - parRang.insert(c.priority(), c.label()); + parRang.insert(c.priority(), i); } return true; } bool LoadConfigStore::reconcileRankOrigin(const LoadConfigs &incoming, LoadConfigs *out, - QString *error) const + QString *error, QVariantMap *fault) const { QHash stocke; for (const LoadConfig &c : m_configs) stocke.insert(c.id(), &c); LoadConfigs resultat; - for (LoadConfig c : incoming) { + for (int i = 0; i < incoming.count(); ++i) { + LoadConfig c = incoming.at(i); const LoadConfig *avant = stocke.value(c.id(), nullptr); const QString marqueAvant = avant ? avant->rankOrigin() : QString(); @@ -180,6 +270,8 @@ bool LoadConfigStore::reconcileRankOrigin(const LoadConfigs &incoming, LoadConfi "moteur n'a pas posé : c'est une affirmation sur un geste " "du moteur, elle ne s'écrit pas depuis un client") .arg(c.label()); + if (fault) + *fault = motifEntree("RANK_ORIGIN_AUTO", i, c.id()); return false; } @@ -205,49 +297,170 @@ bool LoadConfigStore::reconcileRankOrigin(const LoadConfigs &incoming, LoadConfi return true; } -bool LoadConfigStore::validateLabels(const LoadConfigs &configs, QString *error) +bool LoadConfigStore::validateLabels(const LoadConfigs &configs, QString *error, QVariantMap *fault, + int from) { - for (const LoadConfig &c : configs) { + for (int i = qMax(0, from); i < configs.count(); ++i) { + const LoadConfig &c = configs.at(i); const qsizetype caracteres = c.label().toUcs4().size(); if (caracteres > MaxLabelChars) { if (error) *error = QStringLiteral("le libellé de la charge \"%1\" compte %2 caractères, %3 au plus") - .arg(c.id().left(64)).arg(caracteres).arg(MaxLabelChars); + .arg(idPourMotif(c.id())).arg(caracteres).arg(MaxLabelChars); + if (fault) { + *fault = motifEntree("LABEL", i, c.id()); + fault->insert(QStringLiteral("length"), caracteres); + fault->insert(QStringLiteral("max"), MaxLabelChars); + } return false; } } return true; } -bool LoadConfigStore::setConfigs(const LoadConfigs &configs, QString *error) +bool LoadConfigStore::idIsWellFormed(const QString &id) { - // Les libellés D'ABORD : les motifs de refus suivants citent le libellé, et un libellé - // démesuré ne doit entrer ni dans la configuration ni dans le journal (Q7). - if (!validateLabels(configs, error)) + // Le jeu est ASCII : un caractère y vaut une unité UTF-16, et size() compte des caractères. + static const QRegularExpression forme( + QRegularExpression::anchoredPattern(QStringLiteral("[A-Za-z0-9._{}-]+"))); + return id.size() <= MaxIdChars && forme.match(id).hasMatch(); +} + +bool LoadConfigStore::validateIds(const LoadConfigs &configs, QString *error, QVariantMap *fault, int from) +{ + static const QRegularExpression horsJeu(QStringLiteral("[^A-Za-z0-9._{}-]")); + for (int i = qMax(0, from); i < configs.count(); ++i) { + const QString id = configs.at(i).id(); + if (idIsWellFormed(id)) + continue; + const qsizetype caracteres = id.toUcs4().size(); + // La position du premier caractère hors jeu, en caractères — pas en unités UTF-16. + const QRegularExpressionMatch m = horsJeu.match(id); + const qsizetype position = m.hasMatch() ? id.left(m.capturedStart()).toUcs4().size() : -1; + if (error) { + *error = caracteres == 0 + ? QStringLiteral("l'identifiant de la charge en position %1 est vide").arg(i + 1) + : position >= 0 + ? QStringLiteral("l'identifiant de la charge en position %1 contient un caractère hors " + "[A-Za-z0-9._{}-] (caractère %2)").arg(i + 1).arg(position + 1) + : QStringLiteral("l'identifiant de la charge en position %1 compte %2 caractères, %3 au plus") + .arg(i + 1).arg(caracteres).arg(MaxIdChars); + } + if (fault) { + *fault = QVariantMap{{QStringLiteral("cause"), QStringLiteral("ID")}, + {QStringLiteral("index"), i}, + {QStringLiteral("length"), caracteres}, + {QStringLiteral("max"), MaxIdChars}}; + if (position >= 0) + fault->insert(QStringLiteral("position"), position); + } + return false; + } + return true; +} + +bool LoadConfigStore::deadlineIsWellFormed(const QString &deadline) +{ + // anchoredPattern, et non ^…$ : « $ » accepterait un saut de ligne final. + static const QRegularExpression forme( + QRegularExpression::anchoredPattern(QStringLiteral("([01][0-9]|2[0-3]):[0-5][0-9]"))); + return deadline.isEmpty() || forme.match(deadline).hasMatch(); +} + +bool LoadConfigStore::validateDeadlines(const LoadConfigs &configs, QString *error, QVariantMap *fault, + int from) +{ + for (int i = qMax(0, from); i < configs.count(); ++i) { + const LoadConfig &c = configs.at(i); + if (deadlineIsWellFormed(c.needs().dailyDeadline())) + continue; + if (error) + *error = QStringLiteral("l'échéance quotidienne de la charge \"%1\" est illisible (\"%2\") : " + "attendu HH:MM, de 00:00 à 23:59, ou absente") + .arg(idPourMotif(c.id()), valeurPourMotif(c.needs().dailyDeadline())); + if (fault) + *fault = motifEntree("DEADLINE", i, c.id()); + return false; + } + return true; +} + +bool LoadConfigStore::validateBounds(const LoadConfigs &configs, QString *error, QVariantMap *fault, int from) +{ + // L'identifiant D'ABORD : tous les motifs suivants le recopient. + return validateIds(configs, error, fault, from) + && validateLabels(configs, error, fault, from) + && validateDeadlines(configs, error, fault, from); +} + +QString LoadConfigStore::leftChars(const QString &texte, int n) +{ + // Couper en points de code puis recomposer : une paire de substitution reste entière. + const QList points = texte.toUcs4(); + if (points.size() <= n) + return texte; + return QString::fromUcs4(reinterpret_cast(points.constData()), qMax(0, n)); +} + +bool LoadConfigStore::setConfigs(const LoadConfigs &configs, QString *error, QVariantMap *fault) +{ + return commit(configs, 0, error, fault); +} + +bool LoadConfigStore::appendConfig(const LoadConfig &config, QString *error, QVariantMap *fault) +{ + LoadConfigs configs = m_configs; + configs.append(config); + // Les bornes de saisie ne portent que sur l'entrée APPORTÉE — voir la déclaration. + return commit(configs, configs.count() - 1, error, fault); +} + +bool LoadConfigStore::commit(const LoadConfigs &configs, int premierBorne, QString *error, QVariantMap *fault) +{ + // Les bornes de saisie D'ABORD : les motifs de refus suivants citent l'identifiant et le + // libellé, et une saisie démesurée ne doit entrer ni dans la configuration ni dans le + // journal (Q7). + if (!validateBounds(configs, error, fault, premierBorne)) return false; // Validation EN BLOC : une seule entrée invalide → rejet total (rien persisté). - for (const LoadConfig &c : configs) { + for (int i = 0; i < configs.count(); ++i) { + const LoadConfig &c = configs.at(i); QString why; - if (!c.isValid(&why)) { + QVariantMap motif; + if (!c.isValid(&why, &motif)) { if (error) *error = QStringLiteral("Config \"%1\" invalide : %2").arg(c.label(), why); + if (fault) { + *fault = motif; + fault->insert(QStringLiteral("index"), i); + if (idIsWellFormed(c.id())) + fault->insert(QStringLiteral("id"), c.id()); + } return false; } } // …puis la cohérence d'ensemble, qu'aucune charge ne peut vérifier seule. - if (!validateSet(configs, error)) + if (!validateSet(configs, error, fault)) return false; - // …puis la BONNE FORME, qui ne s'applique qu'ici. load() ne l'appelle pas : elle refuse - // une écriture, elle n'écarte jamais une entrée déjà persistée (LM-1209-a). - if (!validateRanks(configs, error)) + // …puis la BONNE FORME, qui ne s'applique qu'ici. load() n'écarte rien sur sa foi : elle + // refuse une écriture, elle n'écarte jamais une entrée déjà persistée (LM-1209-a). + if (!validateRanks(configs, error, fault)) return false; 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/etm/config/loadconfigstore.h b/energyplugin/etm/config/loadconfigstore.h index 817defc..d6b3c16 100644 --- a/energyplugin/etm/config/loadconfigstore.h +++ b/energyplugin/etm/config/loadconfigstore.h @@ -41,9 +41,32 @@ public: * \brief Remplace l'ensemble des configs : valide tout → persiste → \c emit changed(). * \param configs Nouvel ensemble (remplace l'existant — l'app envoie la liste complète). * \param[out] error Message FR si rejet (aucune écriture dans ce cas). + * \param[out] fault Si rejet, les \c faultParams du motif \c LOAD_CONFIG_REFUSED : \c cause + * et l'endroit (\c index, \c id, \c field, …) — liste des causes : \c DecisionCode::LoadConfigRefused. * \return true si validé + persisté ; false si invalide ou échec d'écriture. */ - bool setConfigs(const LoadConfigs &configs, QString *error = nullptr); + bool setConfigs(const LoadConfigs &configs, QString *error = nullptr, QVariantMap *fault = nullptr); + + /*! + * \brief AJOUT d'une entrée par le MOTEUR (auto-provisionnement d'une borne, LM-1209). + * + * Mêmes contrôles que \c setConfigs(), à une différence près : les bornes de saisie + * (\c validateBounds() — identifiant, libellé, échéance) ne portent que sur l'entrée AJOUTÉE. + * Un client renvoie l'ensemble entier : il l'apporte, il en répond, et une entrée héritée hors + * règle bloque son écriture jusqu'à ce qu'il la répare. Le moteur n'apporte qu'une entrée ; il + * ne sait pas réparer les autres (raccourcir un libellé humain n'est pas son geste), et en être + * bloqué laisserait la borne arbitrée sans rang réglable — le trou refermé en 3g-2. + * + * \param config Entrée à ajouter ; son \c id ne doit pas déjà figurer dans l'ensemble. + * \param[out] error Message FR si rejet (aucune écriture dans ce cas). + * \param[out] fault Comme pour \c setConfigs(). + * \return true si validé + persisté. + * + * \note La cohérence d'ensemble (\c validateSet()) et l'unicité des rangs (\c validateRanks()) + * restent contrôlées sur l'ensemble complet : la première protège un organe de deux + * commandeurs, et un rang en double hérité bloque encore l'ajout, comme avant ce lot. + */ + bool appendConfig(const LoadConfig &config, QString *error = nullptr, QVariantMap *fault = nullptr); /*! \brief Cohérence de l'ENSEMBLE, ce qu'aucune charge ne peut vérifier seule. * @@ -60,15 +83,21 @@ public: * \c setConfigs(), donc par cette vérification. * * \param configs Ensemble complet à confronter (les charges désactivées sont ignorées). - * \param[out] error Motif de refus, en français, exploitable tel quel. + * \param[out] error Motif de refus, en français, exploitable tel quel ; un libellé n'y est + * cité que sur ses 64 premiers caractères (celui d'une entrée héritée n'est pas borné). + * \param[out] fault Si refus : \c ID_DUPLICATE (\c index, \c id) ou \c THING_SHARED + * (\c indexes, \c ids — la paire de charges). * \return true si l'ensemble est cohérent ; false au PREMIER conflit rencontré. */ - static bool validateSet(const LoadConfigs &configs, QString *error = nullptr); + static bool validateSet(const LoadConfigs &configs, QString *error = nullptr, + QVariantMap *fault = nullptr); /*! * \brief Contrôle de BONNE FORME, appliqué aux ÉCRITURES et à elles seules. §12 / LM-1209-a. * - * \param configs Ensemble candidat. \param error Motif du refus, si refus. + * \param configs Ensemble candidat. \param error Motif du refus, si refus ; un libellé n'y + * est cité que sur ses 64 premiers caractères. + * \param[out] fault Si refus : \c RANK_DUPLICATE (\c indexes, \c ids, \c rank). * \return Faux si deux charges portent le même rang. * * \note **Distincte de \c validateSet(), et la distinction est de sûreté, pas de propreté** @@ -81,10 +110,11 @@ public: * héritée à rangs doublés : des charges disparaîtraient de la configuration ET de * l'arbitrage, sans \c applySafeState — une règle de contrat convertie en perte de * configuration silencieuse. - * \note N'est donc JAMAIS appelée par \c load(). Un doublon hérité est **signalé + * \note \c load() n'écarte donc rien sur sa foi. Un doublon hérité est **signalé * bruyamment et CONSERVÉ** ; la première écriture cliente le répare. */ - static bool validateRanks(const LoadConfigs &configs, QString *error = nullptr); + static bool validateRanks(const LoadConfigs &configs, QString *error = nullptr, + QVariantMap *fault = nullptr); //! Plus long libellé de charge accepté à l'écriture, en caractères (points de code Unicode). static constexpr int MaxLabelChars = 256; @@ -99,19 +129,99 @@ public: * * \param configs Ensemble candidat. \param[out] error Motif du refus, en français ; il nomme * la charge par son identifiant et donne la longueur, jamais le libellé. + * \param[out] fault Si refus : \c LABEL (\c index, \c id, \c length, \c max). + * \param from Premier index contrôlé (les précédents ne le sont pas) ; voir \c appendConfig(). * \return Faux si un libellé compte plus de \c MaxLabelChars caractères. * * \note Même famille que \c validateRanks() (\rule{LM-1209-a-bis}) : un refus d'écriture, jamais - * un écart au chargement. \c load() ne l'appelle pas — un libellé plus long déjà persisté est - * **chargé tel quel, signalé, jamais tronqué** ; la première écriture devra le raccourcir. + * un écart au chargement. \c load() n'écarte rien sur sa foi — un libellé plus long déjà + * persisté est **chargé tel quel, signalé, jamais tronqué** ; la première écriture cliente + * devra le raccourcir. */ - static bool validateLabels(const LoadConfigs &configs, QString *error = nullptr); + static bool validateLabels(const LoadConfigs &configs, QString *error = nullptr, + QVariantMap *fault = nullptr, int from = 0); + + //! Plus long identifiant de charge accepté à l'écriture, en caractères. + static constexpr int MaxIdChars = 128; + + /*! + * \brief \return Vrai si \p id est un identifiant de charge bien formé : 1 à \c MaxIdChars + * caractères, tous dans \c [A-Za-z0-9._{}-]. + * \param id Identifiant à juger. + * + * L'identifiant est une CLÉ, pas un texte : il part dans chaque \c GetPlan (\c loads[].id, et + * clé des actions), il est recopié dans les motifs et le journal. Le jeu couvre les deux formes + * réelles — un ThingId entre accolades (38 caractères) et un nom court à tirets. + */ + static bool idIsWellFormed(const QString &id); + + /*! + * \brief Borne les identifiants, à l'ÉCRITURE seulement (Q7). + * \param configs Ensemble candidat. + * \param[out] error Motif du refus, en français ; il donne la position de l'entrée et la + * longueur, jamais l'identifiant refusé. + * \param[out] fault Si refus : \c ID (\c index, \c length, \c max, et \c position, premier + * caractère hors jeu, s'il y en a un). Jamais \c id : c'est lui qui est refusé. + * \param from Premier index contrôlé ; voir \c appendConfig(). + * \return Faux si un identifiant n'est pas \c idIsWellFormed(). + * + * \note Même partage que \c validateLabels() : un identifiant hors règle déjà persisté est + * **chargé tel quel et signalé** — le changer reviendrait à retirer une charge et en créer + * une autre, ce qu'une lecture n'a pas à décider. + */ + static bool validateIds(const LoadConfigs &configs, QString *error = nullptr, + QVariantMap *fault = nullptr, int from = 0); + + /*! + * \brief \return Vrai si \p deadline est vide ou de la forme \c HH:MM, de 00:00 à 23:59, deux + * chiffres de chaque côté. \param deadline Valeur de \c needs.dailyDeadline. + */ + static bool deadlineIsWellFormed(const QString &deadline); + + /*! + * \brief Contrôle l'échéance quotidienne (\c needs.dailyDeadline), à l'ÉCRITURE seulement. + * \param configs Ensemble candidat. + * \param[out] error Motif du refus, en français ; la valeur refusée n'y est citée que tronquée. + * \param[out] fault Si refus : \c DEADLINE (\c index, \c id). + * \param from Premier index contrôlé ; voir \c appendConfig(). + * \return Faux si une échéance n'est pas \c deadlineIsWellFormed(). + * + * \note Au chargement, l'entrée est conservée mais l'échéance illisible est **retirée de la + * mémoire** (le fichier n'est pas réécrit) : elle n'atteint ni l'arbitre ni l'optimiseur. + * Sans échéance, une obligation en énergie n'a pas de cadence et rien n'est acheté pour + * elle — le côté sûr pour un mécanisme qui achète. Une échéance illisible n'était de toute + * façon résolue en aucun instant ; elle partait seulement, brute, dans \c GetPlan. + */ + static bool validateDeadlines(const LoadConfigs &configs, QString *error = nullptr, + QVariantMap *fault = nullptr, int from = 0); + + /*! + * \brief Les bornes de SAISIE, dans l'ordre : identifiants, libellés, échéances (Q7). + * + * Avant tout autre contrôle, et avant \c reconcileRankOrigin() : les motifs suivants citent + * l'identifiant et le libellé, qui doivent donc déjà être bornés pour entrer au journal. + * + * \param configs Ensemble candidat. \param[out] error Motif du refus, en français. + * \param[out] fault Si refus, le motif de \c validateIds(), \c validateLabels() ou + * \c validateDeadlines(). \param from Premier index contrôlé ; voir \c appendConfig(). + * \return Faux au premier manquement. + */ + static bool validateBounds(const LoadConfigs &configs, QString *error = nullptr, + QVariantMap *fault = nullptr, int from = 0); + + /*! + * \brief \return Les \p n premiers caractères de \p texte — en points de code, jamais au milieu + * d'une paire de substitution UTF-16 (à l'inverse de \c QString::left()). + * \param texte Texte à couper. \param n Nombre de caractères gardés. + */ + static QString leftChars(const QString &texte, int n); /*! * \brief Réconcilie \c rankOrigin d'une écriture CLIENTE avec l'état persisté. LM-1209-c. * * \param incoming Ensemble reçu du client. \param error Motif du refus, si refus. * \param[out] out Ensemble à persister, \c rankOrigin réconcilié. + * \param[out] fault Si refus : \c RANK_ORIGIN_AUTO (\c index, \c id). * \return Faux si le client tente de FABRIQUER un \c "auto". * * Trois règles, et la deuxième est celle qu'on se serait trompé à écrire : @@ -129,7 +239,7 @@ public: * seule voie, et elle est explicite. L'omission ne lève rien (règle 2). */ bool reconcileRankOrigin(const LoadConfigs &incoming, LoadConfigs *out, - QString *error = nullptr) const; + QString *error = nullptr, QVariantMap *fault = nullptr) const; signals: /*! \brief Émis après une persistance réussie de \c setConfigs(). */ @@ -139,6 +249,8 @@ private: QString filePath() const; bool load(); bool save(QString *error) const; + // Valide puis persiste ; les bornes de saisie ne portent qu'à partir de l'index premierBorne. + bool commit(const LoadConfigs &configs, int premierBorne, QString *error, QVariantMap *fault); LoadConfigs m_configs; }; diff --git a/energyplugin/etm/energyarbitrator.cpp b/energyplugin/etm/energyarbitrator.cpp index 4c69651..ab5cda3 100644 --- a/energyplugin/etm/energyarbitrator.cpp +++ b/energyplugin/etm/energyarbitrator.cpp @@ -1662,7 +1662,7 @@ void EnergyArbitrator::provisionEvChargerConfig(EvCharger *ev) return; const QString id = ev->thing()->id().toString(); - LoadConfigs configs = m_loadConfigStore->configs(); + const LoadConfigs configs = m_loadConfigStore->configs(); // LM-1209 — EN QUEUE, jamais en tête. L'auto-provisionnement REMPLIT UN TROU : sans entrée, // la borne serait arbitrée sans rang réglable. Il ne s'ensuit rien sur sa PLACE — le rang @@ -1688,7 +1688,10 @@ void EnergyArbitrator::provisionEvChargerConfig(EvCharger *ev) LoadConfig c; c.setId(id); - c.setLabel(ev->name()); + // Q7 — le libellé est le nom du Thing, COUPÉ à la borne de saisie (en caractères, jamais au + // milieu d'une paire de substitution). Un nom plus long n'est pas une raison de laisser la + // borne sans rang réglable : le libellé n'est qu'un nom, l'installateur le renomme. + c.setLabel(LoadConfigStore::leftChars(ev->name(), LoadConfigStore::MaxLabelChars)); c.setAdapter(QStringLiteral("evcharger")); c.setMode(QStringLiteral("dynamic")); // Le domaine d'une borne n'est pas une devinette : c'est le mécanisme lui-même. La réserve @@ -1704,10 +1707,12 @@ void EnergyArbitrator::provisionEvChargerConfig(EvCharger *ev) // rang est un choix » — un client ne peut pas le deviner, rien dans le nombre ne le dit. c.setRankOrigin(QStringLiteral("auto")); c.setEnabled(true); - configs.append(c); + // appendConfig() et non setConfigs() : les bornes de saisie ne portent que sur l'entrée + // AJOUTÉE. Un libellé hérité trop long sur une AUTRE charge refuserait sinon l'ajout — le + // moteur ne sait pas le réparer, et c'est l'installateur qu'il attend (relecture etm72). QString erreur; - if (!m_loadConfigStore->setConfigs(configs, &erreur)) { + if (!m_loadConfigStore->appendConfig(c, &erreur)) { // 7-c — l'échec ne doit pas être plus discret que le succès : sans entrée, la borne // reste arbitrée mais absente de GetLoadConfig, et c'est précisément le trou qu'on // vient refermer. @@ -1720,7 +1725,7 @@ void EnergyArbitrator::provisionEvChargerConfig(EvCharger *ev) if (!m_provisionRefuse.contains(id)) { m_provisionRefuse.insert(id); qCWarning(dcNymeaEnergy()) << "[EnergyArbitrator] Configuration automatique de la borne" - << ev->name() << "REFUSÉE :" << erreur + << c.label() << "REFUSÉE :" << erreur << "— elle sera arbitrée sans rang configurable. Réessayé" << "à chaque cycle ; cet avertissement ne sera pas répété."; } @@ -1728,7 +1733,7 @@ void EnergyArbitrator::provisionEvChargerConfig(EvCharger *ev) } m_provisionRefuse.remove(id); - qCInfo(dcNymeaEnergy()) << "[EnergyArbitrator] Borne" << ev->name() + qCInfo(dcNymeaEnergy()) << "[EnergyArbitrator] Borne" << c.label() << "ajoutée à la configuration des charges (rang" << c.priority() << ", domaine ev, rankOrigin=auto) — entrée créée automatiquement, " "insérée EN QUEUE sans déplacer personne. Le rang est un défaut, " @@ -3117,15 +3122,29 @@ void EnergyArbitrator::tracerCycle(const SurplusContext &ctx, const Plan &plan, ligne.insert(QStringLiteral("t"), OptimizerTrace::horodatage(now)); const bool optimiseur = plan.strategy == QLatin1String(OptimizerPlan::StrategieOptimiseur); const quint64 id = m_socketScheduler ? m_socketScheduler->idRequeteDuCycle() : 0; + // Q7 — une requête trop longue pour la ligne du protocole n'est PAS partie : la trace n'en + // garde que la TAILLE (`requestBytes`, octets de la ligne). La recopier écrirait plus de 1 Mio + // par cycle, jusqu'au plafond journalier, pour une requête que personne n'a reçue — et le + // rejeu la renverrait à un optimiseur qui la refuserait. La règle est celle du client + // (getPlanLine, kMaxLineBytes), pas une seconde estimation. + auto requeteOuTaille = [&ligne](quint64 idFil, const QVariantMap &requete) { + const qsizetype octets = OptimizerClient::getPlanLine(idFil, requete).size(); + if (octets > OptimizerClient::kMaxLineBytes) + ligne.insert(QStringLiteral("requestBytes"), octets); + else + ligne.insert(QStringLiteral("request"), requete); + }; if (id != 0) { - // La requête RÉELLEMENT envoyée ; sa réponse complétera la ligne (ou « no-response »). - ligne.insert(QStringLiteral("request"), m_socketScheduler->requeteDuCycle()); + // La requête soumise au client ; sa réponse complétera la ligne (ou « no-response »). + // Trop longue, elle n'a pas été écrite et sa réponse sera l'erreur `request-too-long`. + requeteOuTaille(id, m_socketScheduler->requeteDuCycle()); } else { // Aucune n'est partie (pas d'endpoint, client non connecté) : celle qui serait partie, - // même fonction, même fil — et c'est le rule-based qui y a répondu. + // même fonction, même fil — et c'est le rule-based qui y a répondu. Même règle de taille, + // calculée sous un identifiant nul : aucun n'a été attribué. OptimizerProtocol::ContexteFil fil = contexteFil(ctx); fil.trigger = m_socketScheduler ? m_socketScheduler->declencheurEnAttente() : QStringLiteral("cycle"); - ligne.insert(QStringLiteral("request"), OptimizerProtocol::getPlanParams(ctx, fil)); + requeteOuTaille(0, OptimizerProtocol::getPlanParams(ctx, fil)); ligne.insert(QStringLiteral("response"), optimiseur ? QVariantMap{{QStringLiteral("error"), QVariantMap{{QStringLiteral("code"), QStringLiteral("not-sent")}}}} diff --git a/energyplugin/etm/scheduler/optimizerclient.cpp b/energyplugin/etm/scheduler/optimizerclient.cpp index bd04902..8bc4a3e 100644 --- a/energyplugin/etm/scheduler/optimizerclient.cpp +++ b/energyplugin/etm/scheduler/optimizerclient.cpp @@ -254,12 +254,7 @@ quint64 OptimizerClient::requestPlan(const QVariantMap ¶ms) const quint64 id = ++m_lastId; m_pendingId = id; - const QByteArray line = serialize(QVariantMap{ - {QStringLiteral("jsonrpc"), QStringLiteral("2.0")}, - {QStringLiteral("id"), id}, - {QStringLiteral("method"), QStringLiteral("GetPlan")}, - {QStringLiteral("params"), params}, - }); + const QByteArray line = getPlanLine(id, params); // §2 (1.2, Q7) : le moteur n'émet jamais de ligne de plus de maxLineBytes. Un optimiseur // conforme la jetterait (-32600 à id null) et la requête finirait en délai dépassé, la cause @@ -704,6 +699,16 @@ void OptimizerClient::countProtocolError(const QString &code, const QString &det emit protocolError(code); } +QByteArray OptimizerClient::getPlanLine(quint64 id, const QVariantMap ¶ms) +{ + return serialize(QVariantMap{ + {QStringLiteral("jsonrpc"), QStringLiteral("2.0")}, + {QStringLiteral("id"), id}, + {QStringLiteral("method"), QStringLiteral("GetPlan")}, + {QStringLiteral("params"), params}, + }); +} + QByteArray OptimizerClient::serialize(const QVariantMap &message) { return QJsonDocument(QJsonObject::fromVariantMap(message)).toJson(QJsonDocument::Compact); diff --git a/energyplugin/etm/scheduler/optimizerclient.h b/energyplugin/etm/scheduler/optimizerclient.h index 7526e9c..c3a14bf 100644 --- a/energyplugin/etm/scheduler/optimizerclient.h +++ b/energyplugin/etm/scheduler/optimizerclient.h @@ -235,6 +235,15 @@ public: */ quint64 requestPlan(const QVariantMap ¶ms); + /*! + * \brief La ligne \c GetPlan exacte que \c requestPlan() écrirait, sans son \c '\\n'. + * \param id Identifiant JSON-RPC de la requête. \param params Ses \c params. + * \return La ligne sérialisée. Sa taille comparée à \c kMaxLineBytes dit si la requête part : + * c'est la règle de \c requestPlan(), qui l'appelle — la trace de décision la lit aussi, + * pour ne pas recopier une requête qui n'est pas partie. + */ + static QByteArray getPlanLine(quint64 id, const QVariantMap ¶ms); + /*! \brief \return Identifiant de la requête attendue, 0 si aucune. */ quint64 pendingRequestId() const { return m_pendingId; } diff --git a/energyplugin/etm/scheduler/optimizertrace.h b/energyplugin/etm/scheduler/optimizertrace.h index 2803cc5..c35ab9e 100644 --- a/energyplugin/etm/scheduler/optimizertrace.h +++ b/energyplugin/etm/scheduler/optimizertrace.h @@ -30,7 +30,9 @@ * * \par Ce qu'une ligne porte (annexe D) * \c t (instant du cycle, heure locale avec décalage), \c request (les paramètres \c GetPlan - * envoyés, ou ceux qui l'auraient été), \c response (ce qui a répondu à CETTE requête), + * envoyés, ou ceux qui l'auraient été) — ou, à sa place, \c requestBytes (taille de la ligne, + * en octets) pour une requête trop longue pour partir (\c request-too-long, Q7), \c response + * (ce qui a répondu à CETTE requête), * \c executed (actions appliquées au cycle), \c strategy (\c optimizer | \c rules | \c none), * \c latencyMs (optimiseur seulement). Jamais le token : il ne voyage que dans le handshake ; * le client masque toute réponse qui le recopierait (\c OptimizerSecret), et l'écrivain masque @@ -195,7 +197,8 @@ public: /*! * \brief Ligne d'un cycle. - * \param ligne \c t, \c request, \c response (si connue), \c executed, \c strategy. + * \param ligne \c t, \c request (ou \c requestBytes), \c response (si connue), \c executed, + * \c strategy. * \param t Instant du cycle (choisit le fichier). * \param idRequete Identifiant de la requête envoyée ce cycle ; 0 = aucune (la ligne part). */ diff --git a/energyplugin/etm/scheduler/socketscheduler.h b/energyplugin/etm/scheduler/socketscheduler.h index a72e779..c650b14 100644 --- a/energyplugin/etm/scheduler/socketscheduler.h +++ b/energyplugin/etm/scheduler/socketscheduler.h @@ -160,7 +160,7 @@ signals: void reponseJugee(const QString &issue, const QString &code); /*! \brief La requête attendue n'a pas eu de réponse utilisable. \param code Code du client - * (\c timeout, \c disconnected, \c rpc-error, \c invalid-response). */ + * (\c timeout, \c disconnected, \c rpc-error, \c invalid-response, \c request-too-long). */ void echecRequete(const QString &code); /*! diff --git a/energyplugin/etm/types/decisionreason.cpp b/energyplugin/etm/types/decisionreason.cpp index 150fbe3..01409c2 100644 --- a/energyplugin/etm/types/decisionreason.cpp +++ b/energyplugin/etm/types/decisionreason.cpp @@ -272,6 +272,39 @@ QString renderFr(const DecisionReason &reason, const QString &label) ou.isEmpty() ? QString() : QStringLiteral(" : ") + ou.join(QStringLiteral(", "))); } + if (c == QLatin1String(DecisionCode::LoadConfigRefused)) { + // L'ENDROIT, comme pour le tarif : une liste de charges ne se corrige pas à l'aveugle. + // Charges numérotées à partir de 1 pour la lecture humaine ; jamais le libellé. + QStringList ou; + if (p.contains(QStringLiteral("index"))) { + const QString id = p.value(QStringLiteral("id")).toString(); + ou << (id.isEmpty() ? QStringLiteral("charge %1").arg(p.value(QStringLiteral("index")).toInt() + 1) + : QStringLiteral("charge %1 « %2 »") + .arg(p.value(QStringLiteral("index")).toInt() + 1).arg(id)); + } + if (p.contains(QStringLiteral("indexes"))) { + const QVariantList n = p.value(QStringLiteral("indexes")).toList(); + if (n.count() == 2) + ou << QStringLiteral("charges %1 et %2").arg(n.at(0).toInt() + 1).arg(n.at(1).toInt() + 1); + } + if (p.contains(QStringLiteral("field"))) + ou << QStringLiteral("champ %1").arg(p.value(QStringLiteral("field")).toString()); + if (p.contains(QStringLiteral("state"))) + ou << QStringLiteral("état %1").arg(p.value(QStringLiteral("state")).toInt()); + if (p.contains(QStringLiteral("rank"))) + ou << QStringLiteral("rang %1").arg(p.value(QStringLiteral("rank")).toInt()); + if (p.contains(QStringLiteral("length"))) + ou << QStringLiteral("%1 caractères sur %2 au plus") + .arg(p.value(QStringLiteral("length")).toLongLong()) + .arg(p.value(QStringLiteral("max")).toInt()); + if (p.contains(QStringLiteral("position"))) + ou << QStringLiteral("caractère %1 hors [A-Za-z0-9._{}-]") + .arg(p.value(QStringLiteral("position")).toLongLong() + 1); + return QStringLiteral("Configuration des charges refusée (%1)%2 — rien n'est modifié") + .arg(p.value(QStringLiteral("cause")).toString(), + ou.isEmpty() ? QString() : QStringLiteral(" : ") + ou.join(QStringLiteral(", "))); + } + if (c == QLatin1String(DecisionCode::SpotMarketNotInBuild)) // LE GESTE : aucun réglage ne l'active, c'est le paquet qui ne le contient pas. return QStringLiteral("Marché spot non disponible dans ce build — aucun fournisseur " @@ -354,7 +387,7 @@ QStringList DecisionCode::knownCodes() EcoFloorGrid, EcoFloorBelowStage, EcoFloorMet, EcoFloorMissed, RecreditSuspended, DerogationActive, DerogationEnded, MissingForecastSite, ForecastPayloadRefused, SpotMarketNotInBuild, - ForecastDateUnreadable, TariffRefused, + ForecastDateUnreadable, TariffRefused, LoadConfigRefused, LockMinStateHold, SgForced, SgRecommended, SgNormal, OptimizerDecision, PriceSlot, DegradedL2, SafeStateRelay, SafeStateSetpoint, SafeStateSgReady}; diff --git a/energyplugin/etm/types/decisionreason.h b/energyplugin/etm/types/decisionreason.h index e7d9777..8bd7876 100644 --- a/energyplugin/etm/types/decisionreason.h +++ b/energyplugin/etm/types/decisionreason.h @@ -374,6 +374,27 @@ constexpr const char *ForecastDateUnreadable = "FORECAST_DATE_UNREADABLE"; */ constexpr const char *TariffRefused = "TARIFF_REFUSED"; +/*! + * \brief Une configuration des charges est REFUSÉE par \c SetLoadConfig — rien n'est modifié. + * params : \c cause, et OÙ. + * - Par entrée — \c index (position dans \c loadConfigs, à partir de 0) et \c id quand + * l'identifiant est bien formé : \c ID (identifiant vide, trop long ou hors jeu ; \c length, + * \c max, \c position du premier caractère hors jeu — jamais \c id), \c LABEL (\c length, + * \c max), \c DEADLINE, \c MODE, \c DOMAIN, \c RANK_ORIGIN (hors énumération), + * \c RANK_ORIGIN_AUTO (\c "auto" posé par un client), \c ADAPTER, \c FIELD_FORBIDDEN, + * \c FIELD_REQUIRED, \c FIELD_RANGE, \c FIELD_DUPLICATE (\c field, et \c state pour un état + * SG-Ready), \c SG_READY_STATE_2, \c ID_DUPLICATE. + * - Entre deux entrées — \c indexes et \c ids (la paire) : \c THING_SHARED, \c RANK_DUPLICATE + * (avec \c rank). + * - Sans entrée : \c WRITE_FAILED (persistance impossible), \c STORE_UNAVAILABLE. + * Liste faisant foi : \c LoadConfig::isValid() et \c LoadConfigStore. + * + * \note **Pas un motif de décision** : \c fault de \c SetLoadConfig à côté de + * \c EnergyErrorInvalidParameter, inchangé. Jamais le libellé : le client nomme la charge avec + * celui qu'il vient lui-même d'envoyer, à la position donnée. + */ +constexpr const char *LoadConfigRefused = "LOAD_CONFIG_REFUSED"; + // --- SG-Ready (PAC) --------------------------------------------------------- //! params: \c state — le verrou minStateHold maintient l'état (court-cycling compresseur). constexpr const char *LockMinStateHold = "LOCK_MIN_STATE_HOLD"; diff --git a/energyplugin/etm/types/loadconfig.cpp b/energyplugin/etm/types/loadconfig.cpp index c3d826b..3ca1ba6 100644 --- a/energyplugin/etm/types/loadconfig.cpp +++ b/energyplugin/etm/types/loadconfig.cpp @@ -194,17 +194,31 @@ QSet LoadConfig::claimedThingIds() const return ids; } -bool LoadConfig::isValid(QString *error) const +bool LoadConfig::isValid(QString *error, QVariantMap *fault) const { - auto fail = [&](const QString &msg) { if (error) *error = msg; return false; }; + // Chaque refus porte DEUX choses : la phrase française du journal, inchangée, et le motif + // publiable de SetLoadConfig — une cause, et l'endroit (champ, état). Les deux sont posées au + // même endroit, refus par refus : une table séparée des causes divergerait de ces branches. + auto fail = [&](const char *cause, const QString &msg, const QString &field = QString(), + int state = 0) { + if (error) *error = msg; + if (fault) { + *fault = QVariantMap{{QStringLiteral("cause"), QLatin1String(cause)}}; + if (!field.isEmpty()) + fault->insert(QStringLiteral("field"), field); + if (state > 0) + fault->insert(QStringLiteral("state"), state); + } + return false; + }; // Validation CONDITIONNELLE au type (contrat rév. 3) — pas "tous champs requis". // Les deux formes (relay-router / etmvariableload) sont mutuellement exclusives ; le schéma // JSON-RPC SET marque TOUS les champs spécifiques en optionnel, c'est ICI qu'on tranche. if (m_id.isEmpty()) - return fail(QStringLiteral("id de charge vide")); + return fail("ID", QStringLiteral("id de charge vide")); if (m_mode != QStringLiteral("fixed") && m_mode != QStringLiteral("dynamic")) - return fail(QStringLiteral("mode doit être \"fixed\" ou \"dynamic\" (reçu: %1)").arg(m_mode)); + return fail("MODE", QStringLiteral("mode doit être \"fixed\" ou \"dynamic\" (reçu: %1)").arg(m_mode)); // Domaine : métadonnée d'intention, OPTIONNELLE. Absent = charge non classée — le plugin ne // la range pas d'office, parce qu'il n'a aucun moyen de deviner juste et qu'un classement @@ -212,8 +226,8 @@ bool LoadConfig::isValid(QString *error) const // l'énumération : une valeur libre acceptée aujourd'hui deviendrait une valeur à supporter // le jour où la couche domaine en fera son discriminant. if (!m_domain.isEmpty() && !knownDomains().contains(m_domain)) - return fail(QStringLiteral("domain \"%1\" hors énumération (attendu : %2, ou absent " - "pour une charge non classée)") + return fail("DOMAIN", QStringLiteral("domain \"%1\" hors énumération (attendu : %2, ou absent " + "pour une charge non classée)") .arg(m_domain, knownDomains().join(QStringLiteral(", ")))); // Origine du rang (LM-1209-c) : même règle d'énumération fermée. Le vide reste licite — il @@ -221,8 +235,8 @@ bool LoadConfig::isValid(QString *error) const // question n'a de sens que face à l'état PERSISTÉ, et elle est donc tranchée à la // frontière d'écriture (LoadConfigStore::reconcileRankOrigin). if (!m_rankOrigin.isEmpty() && !knownRankOrigins().contains(m_rankOrigin)) - return fail(QStringLiteral("rankOrigin \"%1\" hors énumération (attendu : %2, ou absent " - "— l'absence n'affirme rien)") + return fail("RANK_ORIGIN", QStringLiteral("rankOrigin \"%1\" hors énumération (attendu : %2, ou absent " + "— l'absence n'affirme rien)") .arg(m_rankOrigin, knownRankOrigins().join(QStringLiteral(", ")))); // LM-300 — UNION DISCRIMINÉE par mécanisme. Le discriminant est \c adapter ; chaque @@ -232,38 +246,50 @@ bool LoadConfig::isValid(QString *error) const const bool aRelays = !m_relays.isEmpty(); const bool aSgReady = !m_sgReady.states.isEmpty(); const bool aLevels = !m_powerLevels.isEmpty() || m_maxPowerW > 0; + // Le champ NOMMÉ par un refus groupé : celui qui est effectivement posé. + const QString champNiveaux = !m_powerLevels.isEmpty() ? QStringLiteral("powerLevels") + : QStringLiteral("maxPowerW"); + const QString champVerrous = m_minOnS != 0 ? QStringLiteral("minOnS") : QStringLiteral("minOffS"); if (m_adapter == QStringLiteral("sg-ready")) { if (aRelays) - return fail(QStringLiteral("sg-ready : relays[] interdit (charge utile d'un autre mécanisme)")); + return fail("FIELD_FORBIDDEN", QStringLiteral("sg-ready : relays[] interdit (charge utile d'un autre mécanisme)"), + QStringLiteral("relays")); if (aLevels) - return fail(QStringLiteral("sg-ready : powerLevels/maxPowerW interdits (charge utile d'un autre mécanisme)")); + return fail("FIELD_FORBIDDEN", QStringLiteral("sg-ready : powerLevels/maxPowerW interdits (charge utile d'un autre mécanisme)"), + champNiveaux); if (!aSgReady) - return fail(QStringLiteral("sg-ready : charge utile sgReady.states[] requise (non vide)")); + return fail("FIELD_REQUIRED", QStringLiteral("sg-ready : charge utile sgReady.states[] requise (non vide)"), + QStringLiteral("sgReady.states")); if (m_mode != QStringLiteral("fixed")) - return fail(QStringLiteral("sg-ready : mode doit être \"fixed\" (4 états normés, pas une modulation)")); + return fail("MODE", QStringLiteral("sg-ready : mode doit être \"fixed\" (4 états normés, pas une modulation)")); if (m_sgReady.minStateHoldS < 0) - return fail(QStringLiteral("sg-ready : minStateHoldS doit être ≥ 0")); + return fail("FIELD_RANGE", QStringLiteral("sg-ready : minStateHoldS doit être ≥ 0"), + QStringLiteral("sgReady.minStateHoldS")); QList vus; for (const LoadConfigSgReadyState &e : m_sgReady.states) { if (e.state < 1 || e.state > 4) - return fail(QStringLiteral("sg-ready : état %1 hors plage (1-4 normés)").arg(e.state)); + return fail("FIELD_RANGE", QStringLiteral("sg-ready : état %1 hors plage (1-4 normés)").arg(e.state), + QStringLiteral("sgReady.states.state")); if (vus.contains(e.state)) - return fail(QStringLiteral("sg-ready : état %1 déclaré deux fois").arg(e.state)); + return fail("FIELD_DUPLICATE", QStringLiteral("sg-ready : état %1 déclaré deux fois").arg(e.state), + QStringLiteral("sgReady.states.state"), e.state); vus.append(e.state); if (e.estimatedPowerW < 0) - return fail(QStringLiteral("sg-ready : état %1 a estimatedPowerW < 0").arg(e.state)); + return fail("FIELD_RANGE", QStringLiteral("sg-ready : état %1 a estimatedPowerW < 0").arg(e.state), + QStringLiteral("sgReady.states.estimatedPowerW"), e.state); for (const QString &r : e.relays) if (r.isEmpty()) - return fail(QStringLiteral("sg-ready : état %1 référence un thingId vide").arg(e.state)); + return fail("FIELD_REQUIRED", QStringLiteral("sg-ready : état %1 référence un thingId vide").arg(e.state), + QStringLiteral("sgReady.states.relays"), e.state); QSet vusEtat; for (const QString &r : e.relays) { // Un même relais dans PLUSIEURS états est normal (état 4 = état 1 + état 3) ; // deux fois dans le MÊME état ne l'est pas. if (vusEtat.contains(normalizedThingId(r))) - return fail(QStringLiteral("sg-ready : état %1 déclare deux fois le Thing %2") - .arg(e.state).arg(r)); + return fail("FIELD_DUPLICATE", QStringLiteral("sg-ready : état %1 déclare deux fois le Thing %2") + .arg(e.state).arg(r), QStringLiteral("sgReady.states.relays"), e.state); vusEtat.insert(normalizedThingId(r)); } } @@ -272,96 +298,109 @@ bool LoadConfig::isValid(QString *error) const // L2. Une PAC dont la configuration ne permet pas de l'exprimer est REFUSÉE, jamais // construite : c'est le garde-fou qui remplace le Q_ASSERT disparu en release. if (!m_sgReady.hasState(2)) - return fail(QStringLiteral("sg-ready : l'état 2 (normal) est OBLIGATOIRE — c'est le " - "repli sûr du mode dégradé L2, sans lui la charge ne peut " - "pas être mise en sécurité")); + return fail("SG_READY_STATE_2", QStringLiteral("sg-ready : l'état 2 (normal) est OBLIGATOIRE — c'est le " + "repli sûr du mode dégradé L2, sans lui la charge ne peut " + "pas être mise en sécurité")); return true; } if (aSgReady) - return fail(QStringLiteral("%1 : sgReady interdit (charge utile du mécanisme sg-ready)").arg(m_adapter)); + return fail("FIELD_FORBIDDEN", QStringLiteral("%1 : sgReady interdit (charge utile du mécanisme sg-ready)").arg(m_adapter), + QStringLiteral("sgReady")); if (m_adapter == QStringLiteral("evcharger")) { // 3g-2 — la borne n'apporte QUE son rang. Ses limites de courant, son nombre de phases // et sa disponibilité viennent du Thing et changent en continu ; les déclarer ici en // ferait une seconde source de vérité, périmée dès la première voiture branchée. if (aRelays) - return fail(QStringLiteral("evcharger : relays[] interdit (charge utile d'un autre mécanisme)")); + return fail("FIELD_FORBIDDEN", QStringLiteral("evcharger : relays[] interdit (charge utile d'un autre mécanisme)"), + QStringLiteral("relays")); if (aLevels) - return fail(QStringLiteral("evcharger : powerLevels/maxPowerW interdits — les limites " - "de la borne viennent du Thing, pas de la configuration")); + return fail("FIELD_FORBIDDEN", QStringLiteral("evcharger : powerLevels/maxPowerW interdits — les limites " + "de la borne viennent du Thing, pas de la configuration"), champNiveaux); if (m_minPowerW > 0) - return fail(QStringLiteral("evcharger : minPowerW interdit — le plancher est dérivé du " - "courant minimal de la borne et du véhicule branché")); + return fail("FIELD_FORBIDDEN", QStringLiteral("evcharger : minPowerW interdit — le plancher est dérivé du " + "courant minimal de la borne et du véhicule branché"), + QStringLiteral("minPowerW")); if (m_minOnS != 0 || m_minOffS != 0) - return fail(QStringLiteral("evcharger : minOnS/minOffS interdits — les verrous d'une " - "borne vivent dans le manager amont")); + return fail("FIELD_FORBIDDEN", QStringLiteral("evcharger : minOnS/minOffS interdits — les verrous d'une " + "borne vivent dans le manager amont"), champVerrous); // Une borne module en continu : le mode le dit, comme pour tout autre mécanisme. On // garde le champ plutôt que de l'exempter, pour que la forme de l'union reste UNE. if (m_mode != QStringLiteral("dynamic")) - return fail(QStringLiteral("evcharger : mode doit être \"dynamic\" (une borne module, " - "elle n'a pas de paliers)")); + return fail("MODE", QStringLiteral("evcharger : mode doit être \"dynamic\" (une borne module, " + "elle n'a pas de paliers)")); return true; } if (m_adapter == QStringLiteral("relay-router")) { // Cas relais (rév. 3) : liste de relais power. powerLevels/maxPowerW DÉRIVÉS (ignorés ici). if (m_mode != QStringLiteral("fixed")) - return fail(QStringLiteral("relay-router : mode doit être \"fixed\"")); + return fail("MODE", QStringLiteral("relay-router : mode doit être \"fixed\"")); if (m_relays.isEmpty()) - return fail(QStringLiteral("relay-router : relays[] requis (non vide)")); + return fail("FIELD_REQUIRED", QStringLiteral("relay-router : relays[] requis (non vide)"), + QStringLiteral("relays")); QSet vus; for (const LoadConfigRelay &r : m_relays) { if (r.thingId.isEmpty()) - return fail(QStringLiteral("relay-router : un relais a un thingId vide")); + return fail("FIELD_REQUIRED", QStringLiteral("relay-router : un relais a un thingId vide"), + QStringLiteral("relays.thingId")); if (r.powerW <= 0) - return fail(QStringLiteral("relay-router : relais %1 a powerW ≤ 0").arg(r.thingId)); + return fail("FIELD_RANGE", QStringLiteral("relay-router : relais %1 a powerW ≤ 0").arg(r.thingId), + QStringLiteral("relays.powerW")); // ECS-110 — deux étages sur le même Thing : le routeur les fusionnerait // silencieusement, et la puissance annoncée serait le double de la réelle. if (vus.contains(normalizedThingId(r.thingId))) - return fail(QStringLiteral("relay-router : le Thing %1 est déclaré sur deux étages") - .arg(r.thingId)); + return fail("FIELD_DUPLICATE", QStringLiteral("relay-router : le Thing %1 est déclaré sur deux étages") + .arg(r.thingId), QStringLiteral("relays.thingId")); vus.insert(normalizedThingId(r.thingId)); } if (m_minOnS < 0 || m_minOffS < 0) - return fail(QStringLiteral("relay-router : minOnS/minOffS doivent être ≥ 0")); + return fail("FIELD_RANGE", QStringLiteral("relay-router : minOnS/minOffS doivent être ≥ 0"), + m_minOnS < 0 ? QStringLiteral("minOnS") : QStringLiteral("minOffS")); // §12 / LM-1202 — même raison qu'en etmvariableload fixed : ici le plancher est le // plus petit palier atteignable, donc il se dérive de relays[]. if (m_minPowerW > 0) - return fail(QStringLiteral("relay-router : minPowerW interdit — le plancher est le " - "plus petit palier dérivé des relais")); + return fail("FIELD_FORBIDDEN", QStringLiteral("relay-router : minPowerW interdit — le plancher est le " + "plus petit palier dérivé des relais"), QStringLiteral("minPowerW")); } else if (m_adapter == QStringLiteral("etmvariableload")) { // Cas thing unique : continu (dynamic) ou multipalier natif (fixed + powerLevels, legacy). if (!m_relays.isEmpty()) - return fail(QStringLiteral("etmvariableload : relays[] interdit (réservé à relay-router)")); + return fail("FIELD_FORBIDDEN", QStringLiteral("etmvariableload : relays[] interdit (réservé à relay-router)"), + QStringLiteral("relays")); if (m_mode == QStringLiteral("fixed")) { if (m_powerLevels.isEmpty()) - return fail(QStringLiteral("etmvariableload fixed : powerLevels requis (non vide)")); + return fail("FIELD_REQUIRED", QStringLiteral("etmvariableload fixed : powerLevels requis (non vide)"), + QStringLiteral("powerLevels")); if (m_powerLevels.first() != 0) - return fail(QStringLiteral("etmvariableload fixed : powerLevels doit inclure 0")); + return fail("FIELD_RANGE", QStringLiteral("etmvariableload fixed : powerLevels doit inclure 0"), + QStringLiteral("powerLevels")); // §12 / LM-1202 — en fixed le plancher est le plus petit palier non nul : il se // DÉRIVE. Le déclarer en plus ouvrirait deux sources d'une même vérité, et rien // ne dirait laquelle fait foi le jour où elles divergent. if (m_minPowerW > 0) - return fail(QStringLiteral("etmvariableload fixed : minPowerW interdit — le " - "plancher est le plus petit palier non nul")); + return fail("FIELD_FORBIDDEN", QStringLiteral("etmvariableload fixed : minPowerW interdit — le " + "plancher est le plus petit palier non nul"), + QStringLiteral("minPowerW")); } else { // dynamic if (m_maxPowerW <= 0) - return fail(QStringLiteral("etmvariableload dynamic : maxPowerW requis (> 0)")); + return fail("FIELD_REQUIRED", QStringLiteral("etmvariableload dynamic : maxPowerW requis (> 0)"), + QStringLiteral("maxPowerW")); if (!m_powerLevels.isEmpty()) - return fail(QStringLiteral("etmvariableload dynamic : powerLevels doit être absent")); + return fail("FIELD_FORBIDDEN", QStringLiteral("etmvariableload dynamic : powerLevels doit être absent"), + QStringLiteral("powerLevels")); // Un plancher au-dessus du plafond rendrait la charge inatteignable : le domaine // {0} ∪ [min, max] serait réduit à {0}, et la charge ne démarrerait jamais. if (m_minPowerW > static_cast(m_maxPowerW)) - return fail(QStringLiteral("etmvariableload dynamic : minPowerW (%1 W) dépasse " - "maxPowerW (%2 W) — la charge ne pourrait jamais démarrer") - .arg(m_minPowerW).arg(m_maxPowerW)); + return fail("FIELD_RANGE", QStringLiteral("etmvariableload dynamic : minPowerW (%1 W) dépasse " + "maxPowerW (%2 W) — la charge ne pourrait jamais démarrer") + .arg(m_minPowerW).arg(m_maxPowerW), QStringLiteral("minPowerW")); } } else { - return fail(QStringLiteral("mécanisme inconnu \"%1\" (attendu \"relay-router\", " - "\"etmvariableload\", \"sg-ready\" ou \"evcharger\")").arg(m_adapter)); + return fail("ADAPTER", QStringLiteral("mécanisme inconnu \"%1\" (attendu \"relay-router\", " + "\"etmvariableload\", \"sg-ready\" ou \"evcharger\")").arg(m_adapter)); } return true; } diff --git a/energyplugin/etm/types/loadconfig.h b/energyplugin/etm/types/loadconfig.h index c8fed30..057fc44 100644 --- a/energyplugin/etm/types/loadconfig.h +++ b/energyplugin/etm/types/loadconfig.h @@ -379,9 +379,14 @@ public: /*! * \brief Valide la cohérence (contrat §4/§6) : adapter, id, mode, paliers/plafond. * \param[out] error Message FR si invalide. + * \param[out] fault Si invalide, le motif publiable de \c SetLoadConfig : \c cause (\c MODE, + * \c DOMAIN, \c RANK_ORIGIN, \c ADAPTER, \c FIELD_FORBIDDEN, \c FIELD_REQUIRED, + * \c FIELD_RANGE, \c FIELD_DUPLICATE, \c SG_READY_STATE_2, ou \c ID pour un id vide), et + * selon le cas \c field (chemin du champ en cause) et \c state (état SG-Ready). Des codes, + * jamais une phrase ; l'appelant y ajoute la position de l'entrée. * \return true si la config est exploitable. */ - bool isValid(QString *error = nullptr) const; + bool isValid(QString *error = nullptr, QVariantMap *fault = nullptr) const; //! \return Forme sérialisée §4. Les champs \c relays/\c minOnS/\c minOffS ne sont émis //! que s'il existe au moins un relais ; \c powerLevels que s'il est non vide. diff --git a/energyplugin/nymeaenergyjsonhandler.cpp b/energyplugin/nymeaenergyjsonhandler.cpp index 67f0b4e..f4d853a 100644 --- a/energyplugin/nymeaenergyjsonhandler.cpp +++ b/energyplugin/nymeaenergyjsonhandler.cpp @@ -195,7 +195,13 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana params.clear(); returns.clear(); description = "Replace the full set of controllable load configs. Rejected as a whole " - "(energyError) if any entry is invalid; persisted and applied atomically."; + "(energyError) if any entry is invalid; persisted and applied atomically. " + "'id' is 1 to 128 characters in [A-Za-z0-9._{}-]; 'label' at most 256 " + "characters (Unicode code points); 'needs.dailyDeadline' empty or HH:MM " + "(00:00-23:59). On refusal: EnergyErrorInvalidParameter, 'fault' " + "LOAD_CONFIG_REFUSED and 'faultParams' {cause, index?, id?, indexes?, ids?, " + "field?, state?, rank?, length?, max?, position?} - codes and positions, never " + "a sentence - and NOTHING is changed."; // Schéma inline (pas objectRef) : le contrat rév. 3 a DEUX formes exclusives // (relay-router : relays[]+minOnS/minOffS — etmvariableload : powerLevels|maxPowerW). // objectRef rendrait TOUS les champs requis → nymea rejetterait une des deux formes AVANT @@ -284,6 +290,10 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana loadItem.insert("o:minOffS", enumValueName(Uint)); params.insert("loadConfigs", QVariantList() << loadItem); returns.insert("energyError", enumRef()); + // Ajoutés le 2026-10-22 (Q7, suite) : OPTIONNELS, présents sur refus seulement. energyError + // garde sa valeur ; un succès rend exactement ce qu'il rendait. + returns.insert("o:fault", enumValueName(String)); // LOAD_CONFIG_REFUSED + returns.insert("o:faultParams", enumValueName(Variant)); registerMethod("SetLoadConfig", description, params, returns, Types::PermissionScopeConfigureThings); // [Phase 2] Ratios canoniques autoconsommation / autonomie (% [0,100]). Champ omis @@ -1139,30 +1149,45 @@ JsonReply *NymeaEnergyJsonHandler::GetLoadConfig(const QVariantMap ¶ms) JsonReply *NymeaEnergyJsonHandler::SetLoadConfig(const QVariantMap ¶ms) { - if (!m_loadConfigStore) { - qCWarning(dcNymeaEnergy()) << "[NymeaEnergy] SetLoadConfig sans store configuré."; - return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorInvalidParameter)}}); - } + // 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 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)") + .arg(renderFr({DecisionCode::LoadConfigRefused, faultParams}), + 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")}}); 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 : 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)) { - qCWarning(dcNymeaEnergy()) << "[NymeaEnergy] SetLoadConfig rejetée:" << why; - return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorInvalidParameter)}}); - } + if (!m_loadConfigStore->reconcileRankOrigin(configs, &reconciliees, nullptr, &faultParams)) + return refuser(faultParams); configs = reconciliees; - if (!m_loadConfigStore->setConfigs(configs, &why)) { - qCWarning(dcNymeaEnergy()) << "[NymeaEnergy] SetLoadConfig rejetée:" << why; - return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorInvalidParameter)}}); - } + 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/energyplugin/nymeaenergyjsonhandler.h b/energyplugin/nymeaenergyjsonhandler.h index 0b6ecdf..cf1e16d 100644 --- a/energyplugin/nymeaenergyjsonhandler.h +++ b/energyplugin/nymeaenergyjsonhandler.h @@ -89,6 +89,9 @@ public: * \c currentPower sans \c totalEnergyConsumed (mesuré sur l'ECS du banc). */ bool progressMesurable(const LoadConfig &c, QString &cause) const; + //! \brief Remplace l'ensemble des configurations de charge. \param params \c loadConfigs. + //! \return \c energyError ; sur refus \c fault \c LOAD_CONFIG_REFUSED et \c faultParams + //! (\c DecisionCode::LoadConfigRefused) — rien n'est modifié. Q_INVOKABLE JsonReply *SetLoadConfig(const QVariantMap ¶ms); // [Phase 2] Ratios canoniques (autoconsommation / autonomie), % [0,100]. diff --git a/tests/auto/decisions/reference/decisions.ndjson.xz b/tests/auto/decisions/reference/decisions.ndjson.xz index 76309d6..7becd81 100644 Binary files a/tests/auto/decisions/reference/decisions.ndjson.xz and b/tests/auto/decisions/reference/decisions.ndjson.xz differ diff --git a/tests/auto/loadmodel/testdecisioncatalogue.cpp b/tests/auto/loadmodel/testdecisioncatalogue.cpp index c339e70..2dd46ce 100644 --- a/tests/auto/loadmodel/testdecisioncatalogue.cpp +++ b/tests/auto/loadmodel/testdecisioncatalogue.cpp @@ -63,7 +63,7 @@ void TestLoadModel::testOptimizerMayEmitOnlyWhitelistedCodes() DecisionCode::EvSurplus, DecisionCode::EvEcoMin, DecisionCode::EvIdle, DecisionCode::MissingForecastSite, DecisionCode::ForecastPayloadRefused, DecisionCode::ForecastDateUnreadable, DecisionCode::TariffRefused, - DecisionCode::SpotMarketNotInBuild}) { + DecisionCode::LoadConfigRefused, DecisionCode::SpotMarketNotInBuild}) { QVERIFY2(!DecisionCode::optimizerMayEmit(QString::fromLatin1(c)), c); } QVERIFY(!DecisionCode::optimizerMayEmit(QStringLiteral("CODE_QUI_NEXISTE_PAS"))); diff --git a/tests/auto/loadmodel/testloadmodel.cpp b/tests/auto/loadmodel/testloadmodel.cpp index 84906d3..9b97864 100644 --- a/tests/auto/loadmodel/testloadmodel.cpp +++ b/tests/auto/loadmodel/testloadmodel.cpp @@ -17,6 +17,7 @@ #include "etm/types/drawcap.h" #include "etm/types/ecotarget.h" #include "etm/config/loadconfigstore.h" +#include "etm/types/decisionreason.h" #include "etm/ratios/energyratioscalculator.h" #include "etm/tariff/tariffconfig.h" #include "etm/forecast/baseloadlog.h" @@ -28,6 +29,8 @@ #include #include #include +#include +#include namespace { @@ -531,6 +534,467 @@ void TestLoadModel::testLabelIsBoundedOnWriteNeverOnLoad() QFile::remove(cfgPath); } +// ── Q7, suite — le refus de SetLoadConfig publie sa cause ────────────────────────────────── +// +// Les identifiants ci-dessous sont INVENTÉS, de même forme que ceux d'une installation : un ThingId +// entre accolades, et des noms courts à tirets. +namespace { +const QString RELAIS_A = QStringLiteral("{aaaaaaaa-0000-4000-8000-0000000000a1}"); +const QString RELAIS_C = QStringLiteral("{aaaaaaaa-0000-4000-8000-0000000000c3}"); +const QString ROUTEUR_B = QStringLiteral("{bbbbbbbb-0000-4000-8000-0000000000b2}"); + +QVariantMap ballonMap() +{ + return {{"id", "ballon-a"}, {"label", "Ballon"}, {"adapter", "relay-router"}, {"mode", "fixed"}, + {"priority", 1}, {"enabled", true}, {"minOnS", 0}, {"minOffS", 0}, + {"relays", QVariantList{QVariantMap{{"thingId", RELAIS_A}, {"powerW", 1000}}}}}; +} + +QVariantMap routeurMap() +{ + return {{"id", ROUTEUR_B}, {"label", "Routeur"}, {"adapter", "etmvariableload"}, + {"mode", "dynamic"}, {"maxPowerW", 3000}, {"priority", 2}, {"enabled", true}}; +} + +QVariantList baseCharges() { return {ballonMap(), routeurMap()}; } + +//! La base, l'entrée \p index modifiée par \p modifs. +QVariantList chargesAvec(int index, const QVariantMap &modifs) +{ + QVariantList l = baseCharges(); + QVariantMap m = l.at(index).toMap(); + for (auto it = modifs.constBegin(); it != modifs.constEnd(); ++it) + m.insert(it.key(), it.value()); + l[index] = m; + return l; +} + +LoadConfigs versConfigs(const QVariantList &l) +{ + LoadConfigs c; + for (const QVariant &v : l) + c.append(LoadConfig::fromMap(v.toMap())); + return c; +} + +QVariantList versCartes(const LoadConfigs &c) +{ + QVariantList l; + for (const LoadConfig &x : c) + l.append(x.toMap()); + return l; +} + +QByteArray lireFichier(const QString &chemin) +{ + QFile f(chemin); + return f.open(QIODevice::ReadOnly) ? f.readAll() : QByteArray(); +} + +QByteArray json(const QVariant &v) { return QJsonDocument::fromVariant(v).toJson(QJsonDocument::Compact); } + +//! Le chemin d'écriture de SetLoadConfig, dans son ordre : bornes, réconciliation, écriture. +bool ecrireCommeLeClient(LoadConfigStore &store, const LoadConfigs &c, QString *why, QVariantMap *motif) +{ + if (!LoadConfigStore::validateBounds(c, why, motif)) + return false; + LoadConfigs r; + if (!store.reconcileRankOrigin(c, &r, why, motif)) + return false; + return store.setConfigs(r, why, motif); +} + +void ecrireFichier(const QString &chemin, const QVariantList &charges) +{ + QFile f(chemin); + QVERIFY(f.open(QIODevice::WriteOnly | QIODevice::Text)); + f.write(QJsonDocument(QJsonObject{{"version", 1}, {"loads", QJsonArray::fromVariantList(charges)}}).toJson()); +} +} + +void TestLoadModel::testLoadConfigRefusalNamesItsCause_data() +{ + QTest::addColumn("charges"); + QTest::addColumn("motif"); + QTest::addColumn("disqueInaccessible"); + + const QVariantMap paire{{"relays", QVariantList{QVariantMap{{"thingId", RELAIS_A}, {"powerW", 1000}}, + QVariantMap{{"thingId", RELAIS_A}, {"powerW", 500}}}}}; + 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{RELAIS_C}}, {"estimatedPowerW", 1500}}; + }; + QVariantMap ballonC = ballonMap(); + ballonC.insert("id", "ballon-c"); + ballonC.insert("priority", 3); + ballonC.insert("relays", QVariantList{QVariantMap{{"thingId", RELAIS_A}, {"powerW", 500}}}); + + auto ligne = [](const char *nom, const QVariantList &charges, const QVariantMap &motif, bool disque = false) { + QTest::newRow(nom) << charges << motif << disque; + }; + // Par entrée : la position, et l'identifiant quand il est bien formé — jamais celui qu'on refuse. + ligne("ID trop long", chargesAvec(0, {{"id", QString(129, QLatin1Char('x'))}}), + {{"cause", "ID"}, {"index", 0}, {"length", 129}, {"max", 128}}); + ligne("ID hors jeu", chargesAvec(0, {{"id", "ballon a"}}), + {{"cause", "ID"}, {"index", 0}, {"length", 8}, {"max", 128}, {"position", 6}}); + ligne("ID vide", chargesAvec(1, {{"id", ""}}), {{"cause", "ID"}, {"index", 1}, {"length", 0}, {"max", 128}}); + ligne("LABEL", chargesAvec(0, {{"label", QString(257, QLatin1Char('L'))}}), + {{"cause", "LABEL"}, {"index", 0}, {"id", "ballon-a"}, {"length", 257}, {"max", 256}}); + ligne("DEADLINE", chargesAvec(0, {{"needs", QVariantMap{{"dailyDeadline", "7:00"}}}}), + {{"cause", "DEADLINE"}, {"index", 0}, {"id", "ballon-a"}}); + ligne("MODE", chargesAvec(0, {{"mode", "variable"}}), {{"cause", "MODE"}, {"index", 0}, {"id", "ballon-a"}}); + ligne("DOMAIN", chargesAvec(0, {{"domain", "garage"}}), {{"cause", "DOMAIN"}, {"index", 0}, {"id", "ballon-a"}}); + ligne("RANK_ORIGIN", chargesAvec(0, {{"rankOrigin", "robot"}}), + {{"cause", "RANK_ORIGIN"}, {"index", 0}, {"id", "ballon-a"}}); + ligne("RANK_ORIGIN_AUTO", chargesAvec(1, {{"rankOrigin", "auto"}}), + {{"cause", "RANK_ORIGIN_AUTO"}, {"index", 1}, {"id", ROUTEUR_B}}); + ligne("ADAPTER", chargesAvec(0, {{"adapter", "pompe"}}), {{"cause", "ADAPTER"}, {"index", 0}, {"id", "ballon-a"}}); + ligne("FIELD_FORBIDDEN", chargesAvec(0, {{"minPowerW", 100}}), + {{"cause", "FIELD_FORBIDDEN"}, {"index", 0}, {"id", "ballon-a"}, {"field", "minPowerW"}}); + ligne("FIELD_REQUIRED", chargesAvec(1, {{"maxPowerW", 0}}), + {{"cause", "FIELD_REQUIRED"}, {"index", 1}, {"id", ROUTEUR_B}, {"field", "maxPowerW"}}); + ligne("FIELD_RANGE", chargesAvec(0, {{"relays", QVariantList{QVariantMap{{"thingId", RELAIS_A}, {"powerW", 0}}}}}), + {{"cause", "FIELD_RANGE"}, {"index", 0}, {"id", "ballon-a"}, {"field", "relays.powerW"}}); + ligne("FIELD_DUPLICATE", chargesAvec(0, paire), + {{"cause", "FIELD_DUPLICATE"}, {"index", 0}, {"id", "ballon-a"}, {"field", "relays.thingId"}}); + ligne("FIELD_DUPLICATE état", baseCharges() << pac({etat(2), etat(3), etat(3)}), + {{"cause", "FIELD_DUPLICATE"}, {"index", 2}, {"id", "pac-c"}, {"field", "sgReady.states.state"}, + {"state", 3}}); + ligne("SG_READY_STATE_2", baseCharges() << pac({etat(3)}), + {{"cause", "SG_READY_STATE_2"}, {"index", 2}, {"id", "pac-c"}}); + // Entre entrées : la paire. + ligne("ID_DUPLICATE", chargesAvec(1, {{"id", "ballon-a"}}), + {{"cause", "ID_DUPLICATE"}, {"index", 1}, {"id", "ballon-a"}}); + ligne("THING_SHARED", baseCharges() << ballonC, + {{"cause", "THING_SHARED"}, {"indexes", QVariantList{0, 2}}, {"ids", QVariantList{"ballon-a", "ballon-c"}}}); + ligne("RANK_DUPLICATE", chargesAvec(1, {{"priority", 1}}), + {{"cause", "RANK_DUPLICATE"}, {"indexes", QVariantList{0, 1}}, {"ids", QVariantList{"ballon-a", ROUTEUR_B}}, + {"rank", 1}}); + // Sans entrée : l'écriture elle-même a échoué. La charge utile est VALIDE (un libellé changé). + ligne("WRITE_FAILED", chargesAvec(0, {{"label", "Ballon du bas"}}), {{"cause", "WRITE_FAILED"}}, true); +} + +void TestLoadModel::testLoadConfigRefusalNamesItsCause() +{ + QFETCH(QVariantList, charges); + QFETCH(QVariantMap, motif); + QFETCH(bool, disqueInaccessible); + + const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-causes.json"; + QFile::remove(cfgPath); + qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8()); + const auto nettoyer = qScopeGuard([&] { QFile::remove(cfgPath); }); + + LoadConfigStore store; + QString err; + QVariantMap recu; + QVERIFY2(ecrireCommeLeClient(store, versConfigs(baseCharges()), &err, &recu), qUtf8Printable(err)); + QVERIFY(recu.isEmpty()); // un succès ne publie aucun motif + const QVariantList enMemoire = versCartes(store.configs()); + const QByteArray surDisque = lireFichier(cfgPath); + QVERIFY(!surDisque.isEmpty()); + + if (disqueInaccessible) + qputenv("NYMEA_ENERGY_LOAD_CONFIG", "/q7-repertoire-absent/energy-load-configuration.json"); + const bool accepte = ecrireCommeLeClient(store, versConfigs(charges), &err, &recu); + qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8()); + + QVERIFY(!accepte); + QVERIFY(!err.isEmpty()); // la phrase du journal reste là + QVERIFY2(json(recu) == json(motif), json(recu).constData()); + // RIEN n'est modifié : ni en mémoire, ni sur disque (octet pour octet). + QCOMPARE(json(versCartes(store.configs())), json(enMemoire)); + QCOMPARE(lireFichier(cfgPath), surDisque); +} + +void TestLoadModel::testLoadConfigRefusedIsRendered() +{ + auto rendu = [](const QVariantMap &p) { return renderFr(DecisionReason(DecisionCode::LoadConfigRefused, p)); }; + QCOMPARE(rendu({{"cause", "ID"}, {"index", 0}, {"length", 8}, {"max", 128}, {"position", 6}}), + QStringLiteral("Configuration des charges refusée (ID) : charge 1, 8 caractères sur 128 au plus, " + "caractère 7 hors [A-Za-z0-9._{}-] — rien n'est modifié")); + QCOMPARE(rendu({{"cause", "FIELD_DUPLICATE"}, {"index", 2}, {"id", "pac-c"}, + {"field", "sgReady.states.state"}, {"state", 3}}), + QStringLiteral("Configuration des charges refusée (FIELD_DUPLICATE) : charge 3 « pac-c », " + "champ sgReady.states.state, état 3 — rien n'est modifié")); + QCOMPARE(rendu({{"cause", "RANK_DUPLICATE"}, {"indexes", QVariantList{0, 1}}, + {"ids", QVariantList{"ballon-a", "b"}}, {"rank", 1}}), + QStringLiteral("Configuration des charges refusée (RANK_DUPLICATE) : charges 1 et 2, rang 1 — " + "rien n'est modifié")); + QCOMPARE(rendu({{"cause", "WRITE_FAILED"}}), + QStringLiteral("Configuration des charges refusée (WRITE_FAILED) — rien n'est modifié")); +} + +void TestLoadModel::testIdIsBoundedOnWriteNeverOnLoad() +{ + const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-ids.json"; + QFile::remove(cfgPath); + qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8()); + const auto nettoyer = qScopeGuard([&] { QFile::remove(cfgPath); }); + QString err; + QVariantMap motif; + LoadConfigStore store; + auto avecId = [](const QString &id) { return versConfigs(chargesAvec(0, {{"id", id}})); }; + + // Acceptés : la borne exacte, et les deux formes réelles (inventées ici) — un ThingId entre + // accolades (38 caractères) et un nom court à tirets ; le point et le souligné aussi. + const QString limite(LoadConfigStore::MaxIdChars, QLatin1Char('x')); + for (const QString &id : {limite, QStringLiteral("{0f1e2d3c-4b5a-4968-8776-a5b4c3d2e1f0}"), + QStringLiteral("ballon-test"), QStringLiteral("pac.jardin_2")}) { + QVERIFY2(ecrireCommeLeClient(store, avecId(id), &err, &motif), qUtf8Printable(id + " : " + err)); + QCOMPARE(store.configs().at(0).id(), id); + } + const QByteArray surDisque = lireFichier(cfgPath); + + // Refusés : 129, un espace, une barre, un accent, un saut de ligne final — rien de modifié. + for (const QString &id : {limite + QLatin1Char('x'), QStringLiteral("ballon test"), + QStringLiteral("ballon/test"), QStringLiteral("bâton"), + QStringLiteral("ballon-test\n")}) { + QVERIFY2(!ecrireCommeLeClient(store, avecId(id), &err, &motif), qUtf8Printable(id)); + QCOMPARE(motif.value("cause").toString(), QStringLiteral("ID")); + QVERIFY(!motif.contains("id")); // l'identifiant refusé n'est jamais recopié + QVERIFY2(!err.contains(id), qUtf8Printable(err)); + QCOMPARE(store.configs().at(0).id(), QStringLiteral("pac.jardin_2")); + QCOMPARE(lireFichier(cfgPath), surDisque); + } + + // ---- Au CHARGEMENT : hors règle, l'entrée est conservée telle quelle, et signalée --------- + QVariantMap long_ = ballonMap(); + long_.insert("id", QString(129, QLatin1Char('x'))); + QVariantMap espace = routeurMap(); + espace.insert("id", "routeur b"); + ecrireFichier(cfgPath, {long_, espace}); + QTest::ignoreMessage(QtWarningMsg, QRegularExpression( + QStringLiteral("IDENTIFIANT HORS RÈGLE : charge \"x{64}…\" \\(129 caractères"))); + QTest::ignoreMessage(QtWarningMsg, QRegularExpression( + QStringLiteral("IDENTIFIANT HORS RÈGLE : charge \"routeur b\" \\(9 caractères"))); + LoadConfigStore relu; + QCOMPARE(relu.configs().count(), 2); + QCOMPARE(relu.configs().at(0).id(), QString(129, QLatin1Char('x'))); + QCOMPARE(relu.configs().at(1).id(), QStringLiteral("routeur b")); + // L'écho verbatim est refusé ; l'identifiant remplacé, il passe. + QVERIFY(!ecrireCommeLeClient(relu, relu.configs(), &err, &motif)); + QCOMPARE(json(motif), json(QVariantMap{{"cause", "ID"}, {"index", 0}, {"length", 129}, {"max", 128}})); + LoadConfigs repare = relu.configs(); + repare[0].setId(QStringLiteral("ballon-a")); + repare[1].setId(ROUTEUR_B); + QVERIFY2(ecrireCommeLeClient(relu, repare, &err, &motif), qUtf8Printable(err)); +} + +void TestLoadModel::testDailyDeadlineIsCheckedOnWriteAndDroppedOnLoad() +{ + const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-deadline.json"; + QFile::remove(cfgPath); + qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8()); + const auto nettoyer = qScopeGuard([&] { QFile::remove(cfgPath); }); + QString err; + QVariantMap motif; + LoadConfigStore store; + auto avecEcheance = [](const QString &h) { + return versConfigs(chargesAvec(0, {{"needs", QVariantMap{{"dailyDeadline", h}, {"minEnergyWhPerDay", 2000}}}})); + }; + + for (const QString &h : {QString(), QStringLiteral("00:00"), QStringLiteral("23:59"), QStringLiteral("12:00")}) { + QVERIFY2(ecrireCommeLeClient(store, avecEcheance(h), &err, &motif), qUtf8Printable(h + " : " + err)); + QCOMPARE(store.configs().at(0).needs().dailyDeadline(), h); + } + const QByteArray surDisque = lireFichier(cfgPath); + for (const QString &h : {QStringLiteral("24:00"), QStringLiteral("7:00"), QStringLiteral("12:60"), + QStringLiteral("12h00"), QStringLiteral("12:00\n"), QStringLiteral(" 12:00")}) { + QVERIFY2(!ecrireCommeLeClient(store, avecEcheance(h), &err, &motif), qUtf8Printable(h)); + QCOMPARE(json(motif), json(QVariantMap{{"cause", "DEADLINE"}, {"index", 0}, {"id", "ballon-a"}})); + QCOMPARE(store.configs().at(0).needs().dailyDeadline(), QStringLiteral("12:00")); + QCOMPARE(lireFichier(cfgPath), surDisque); + } + + // ---- Au CHARGEMENT : l'entrée est gardée, l'échéance illisible est RETIRÉE de la mémoire --- + QVariantMap illisible = ballonMap(); + illisible.insert("needs", QVariantMap{{"dailyDeadline", "7:00"}, {"minEnergyWhPerDay", 2000}}); + QVariantMap lisible = routeurMap(); + lisible.insert("needs", QVariantMap{{"dailyDeadline", "07:00"}, {"minEnergyWhPerDay", 1000}}); + ecrireFichier(cfgPath, {illisible, lisible}); + const QByteArray herite = lireFichier(cfgPath); + QTest::ignoreMessage(QtWarningMsg, QRegularExpression( + QStringLiteral("ÉCHÉANCE ILLISIBLE : charge \"ballon-a\", needs.dailyDeadline \"7:00\""))); + LoadConfigStore relu; + QCOMPARE(relu.configs().count(), 2); + QCOMPARE(relu.configs().at(0).needs().dailyDeadline(), QString()); // retirée + QCOMPARE(relu.configs().at(0).needs().minEnergyWhPerDay(), 2000); // le reste demeure + QCOMPARE(relu.configs().at(1).needs().dailyDeadline(), QStringLiteral("07:00")); // contre-épreuve + QCOMPARE(lireFichier(cfgPath), herite); // le fichier n'est pas réécrit par une lecture +} + +void TestLoadModel::testLeftCharsNeverSplitsAPair() +{ + const QString prise = QString::fromUcs4(U"\U0001F50C"); + // L'emoji est le 256e caractère : gardé ENTIER — QString::left(256) l'aurait coupé en deux. + const QString nom = QString(255, QLatin1Char('a')) + prise + QString(44, QLatin1Char('b')); + QCOMPARE(nom.toUcs4().size(), qsizetype(300)); + QVERIFY(nom.left(256).back().isHighSurrogate()); // le piège que leftChars() évite + const QString coupe = LoadConfigStore::leftChars(nom, 256); + QCOMPARE(coupe, QString(255, QLatin1Char('a')) + prise); + QCOMPARE(coupe.toUcs4().size(), qsizetype(256)); + QCOMPARE(coupe.size(), qsizetype(257)); + // L'emoji est le 257e : il part ENTIER, rien n'en reste. + QCOMPARE(LoadConfigStore::leftChars(QString(256, QLatin1Char('a')) + prise, 256), QString(256, QLatin1Char('a'))); + // Plus court que la borne : rendu tel quel. + QCOMPARE(LoadConfigStore::leftChars(prise, 256), prise); +} + +void TestLoadModel::testAppendConfigBoundsOnlyTheAddedEntry() +{ + const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-append.json"; + QFile::remove(cfgPath); + qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8()); + const auto nettoyer = qScopeGuard([&] { QFile::remove(cfgPath); }); + + // Une configuration HÉRITÉE dont une entrée porte un libellé de 300 caractères. + QVariantMap herite = ballonMap(); + const QString tropLong(300, QLatin1Char('L')); + herite.insert("label", tropLong); + ecrireFichier(cfgPath, {herite}); + QTest::ignoreMessage(QtWarningMsg, QRegularExpression(QStringLiteral("LIBELLÉ TROP LONG"))); + LoadConfigStore store; + QCOMPARE(store.configs().count(), 1); + + QString err; + QVariantMap motif; + // Le moteur ajoute une borne : le libellé hérité ne la bloque pas. + LoadConfig borne = LoadConfig::fromMap(borneMap(BORNE, 2)); + borne.setLabel(QString(LoadConfigStore::MaxLabelChars, QLatin1Char('B'))); + QVERIFY2(store.appendConfig(borne, &err, &motif), qUtf8Printable(err)); + QCOMPARE(store.configs().count(), 2); + QCOMPARE(store.configs().at(0).label(), tropLong); // l'hérité, intact + LoadConfigStore relu; // et persisté avec lui + QCOMPARE(relu.configs().count(), 2); + + // Contre-épreuves : l'entrée AJOUTÉE reste bornée… + LoadConfig autre = LoadConfig::fromMap(borneMap(QStringLiteral("{dddddddd-0000-0000-0000-0000000000f1}"), 3)); + autre.setLabel(QString(LoadConfigStore::MaxLabelChars + 1, QLatin1Char('B'))); + QVERIFY(!store.appendConfig(autre, &err, &motif)); + QCOMPARE(motif.value("cause").toString(), QStringLiteral("LABEL")); + QCOMPARE(motif.value("index").toInt(), 2); + QCOMPARE(store.configs().count(), 2); + // …et l'écriture CLIENTE de l'ensemble reste refusée tant que l'hérité n'est pas raccourci. + QVERIFY(!store.setConfigs(store.configs(), &err, &motif)); + QCOMPARE(json(motif), json(QVariantMap{{"cause", "LABEL"}, {"index", 0}, {"id", "ballon-a"}, + {"length", 300}, {"max", 256}})); +} + +// ── relecture q7-suite — le journal ne recopie jamais une saisie telle quelle ────────────────── +namespace { +//! Ce qu'un client glisse dans un champ pour fabriquer une ligne de journal. +const QString FORGE = QStringLiteral("x\n[NymeaEnergy] FAUX"); + +//! Capture les messages Qt UN PAR UN : une fausse ligne ne se voit qu'à l'intérieur d'un message. +class Messages +{ +public: + Messages() { s_courant = this; s_origine = qInstallMessageHandler(&Messages::collecte); } + ~Messages() { qInstallMessageHandler(s_origine); s_courant = nullptr; } + + bool contient(const QString &fragment) const + { + for (const QString &m : m_liste) + if (m.contains(fragment)) + return true; + return false; + } + //! Les lignes qu'un message AJOUTE au journal en se faisant passer pour un message du moteur. + QStringList faussesLignes() const + { + QStringList f; + for (const QString &m : m_liste) { + const QStringList lignes = m.split(QLatin1Char('\n')); + for (int i = 1; i < lignes.count(); ++i) + if (lignes.at(i).startsWith(QLatin1String("[NymeaEnergy]"), Qt::CaseInsensitive)) + f << lignes.at(i); + } + return f; + } + +private: + static void collecte(QtMsgType type, const QMessageLogContext &ctx, const QString &msg) + { + if (s_courant) + s_courant->m_liste << msg; + if (s_origine) + s_origine(type, ctx, msg); + } + QStringList m_liste; + static inline Messages *s_courant = nullptr; + static inline QtMessageHandler s_origine = nullptr; +}; +} + +void TestLoadModel::testInheritedValuesAreEscapedInTheJournal() +{ + // Avant Q7, un client pouvait persister un identifiant ou une échéance portant un saut de + // ligne. load() les cite : échappés, ils ne fabriquent aucune fausse ligne de journal. + const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-journal.json"; + QFile::remove(cfgPath); + qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8()); + const auto nettoyer = qScopeGuard([&] { QFile::remove(cfgPath); }); + + QVariantMap idForge = ballonMap(); + idForge.insert("id", FORGE); + idForge.insert("label", QString(300, QLatin1Char('L'))); // LIBELLÉ TROP LONG cite l'identifiant + QVariantMap echeanceForgee = routeurMap(); + echeanceForgee.insert("needs", QVariantMap{{"dailyDeadline", FORGE}, {"minEnergyWhPerDay", 1000}}); + ecrireFichier(cfgPath, {idForge, echeanceForgee}); + + Messages journal; + LoadConfigStore relu; + QCOMPARE(relu.configs().count(), 2); + // Non vide : les trois avertissements sont partis, et ils citent la valeur ÉCHAPPÉE. + QVERIFY(journal.contient(QStringLiteral("LIBELLÉ TROP LONG"))); + QVERIFY(journal.contient(QStringLiteral("IDENTIFIANT HORS RÈGLE : charge \"x\\n[NymeaEnergy] FAUX\""))); + QVERIFY(journal.contient(QStringLiteral("needs.dailyDeadline \"x\\n[NymeaEnergy] …\""))); + QVERIFY2(journal.faussesLignes().isEmpty(), qUtf8Printable(journal.faussesLignes().join(QStringLiteral(" | ")))); +} + +void TestLoadModel::testInheritedLabelIsCutInSetRefusals() +{ + // [m-1] L'ajout d'une borne ne borne que l'entrée ajoutée ; ses refus d'ENSEMBLE citaient + // pourtant le libellé hérité en entier, et provisionEvChargerConfig() le journalise. + const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-libelle-motif.json"; + QFile::remove(cfgPath); + qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8()); + const auto nettoyer = qScopeGuard([&] { QFile::remove(cfgPath); }); + + // Héritée : rang 2, libellé de 300 caractères, et elle commande le Thing de la borne. + const QString tropLong = QString(299, QLatin1Char('L')) + QLatin1Char('Z'); + QVariantMap herite = ballonMap(); + herite.insert("label", tropLong); + herite.insert("priority", 2); + herite.insert("relays", QVariantList{QVariantMap{{"thingId", BORNE}, {"powerW", 1000}}}); + ecrireFichier(cfgPath, {herite}); + QTest::ignoreMessage(QtWarningMsg, QRegularExpression(QStringLiteral("LIBELLÉ TROP LONG"))); + LoadConfigStore store; + QCOMPARE(store.configs().count(), 1); + const QString cite = LoadConfigStore::leftChars(tropLong, 64) + QStringLiteral("…"); + QString err; + QVariantMap motif; + + // THING_SHARED : le motif publié est inchangé, la phrase ne cite que 64 caractères. + QVERIFY(!store.appendConfig(LoadConfig::fromMap(borneMap(BORNE, 3)), &err, &motif)); + QCOMPARE(json(motif), json(QVariantMap{{"cause", "THING_SHARED"}, {"indexes", QVariantList{0, 1}}, + {"ids", QVariantList{"ballon-a", BORNE}}})); + QVERIFY2(err.contains(cite) && !err.contains(tropLong), qUtf8Printable(err)); + + // RANK_DUPLICATE : idem. + const QString autreBorne = QStringLiteral("{dddddddd-0000-0000-0000-0000000000f2}"); + QVERIFY(!store.appendConfig(LoadConfig::fromMap(borneMap(autreBorne, 2)), &err, &motif)); + QCOMPARE(json(motif), json(QVariantMap{{"cause", "RANK_DUPLICATE"}, {"indexes", QVariantList{0, 1}}, + {"ids", QVariantList{"ballon-a", autreBorne}}, {"rank", 2}})); + QVERIFY2(err.contains(cite) && !err.contains(tropLong), qUtf8Printable(err)); + QCOMPARE(store.configs().count(), 1); +} + void TestLoadModel::testRankOriginFollowsTheRankNotTheWrite() { // [LM-1209-c] La marque dit « ce rang est un défaut, pas un choix ». Ce qui la lève est diff --git a/tests/auto/loadmodel/testloadmodel.h b/tests/auto/loadmodel/testloadmodel.h index eee8283..17b4c5d 100644 --- a/tests/auto/loadmodel/testloadmodel.h +++ b/tests/auto/loadmodel/testloadmodel.h @@ -77,6 +77,27 @@ private slots: void testRankOriginFollowsTheRankNotTheWrite(); // Protocole optimiseur 1.2, Q7 — le libellé est borné à l'écriture, jamais au chargement void testLabelIsBoundedOnWriteNeverOnLoad(); + // Q7, suite — le refus de SetLoadConfig publie sa cause ; id et échéance bornés à l'écriture + //! Chaque cause de refus : son motif (cause et endroit), et rien de modifié, ni en mémoire + //! ni sur disque. + void testLoadConfigRefusalNamesItsCause_data(); + void testLoadConfigRefusalNamesItsCause(); + //! Le motif se rend en français, avec l'endroit et sans le libellé. + void testLoadConfigRefusedIsRendered(); + //! id : 128 caractères de [A-Za-z0-9._{}-] à l'écriture ; une config héritée hors règle est + //! chargée telle quelle et signalée. + void testIdIsBoundedOnWriteNeverOnLoad(); + //! dailyDeadline : vide ou HH:MM à l'écriture ; héritée illisible, l'entrée est chargée et + //! l'échéance retirée de la mémoire. + void testDailyDeadlineIsCheckedOnWriteAndDroppedOnLoad(); + //! Couper en caractères ne coupe jamais une paire de substitution. + void testLeftCharsNeverSplitsAPair(); + //! L'ajout par le moteur ne borne que l'entrée ajoutée — un hérité hors règle ne le bloque pas. + void testAppendConfigBoundsOnlyTheAddedEntry(); + //! Relecture q7-suite : une valeur héritée citée par load() est échappée — aucune fausse ligne. + void testInheritedValuesAreEscapedInTheJournal(); + //! Relecture q7-suite (m-1) : un refus d'ensemble ne cite que 64 caractères d'un libellé hérité. + void testInheritedLabelIsCutInSetRefusals(); // ── LM-1006-1 — le plafond de soutirage, étape 1 ─────────────────────── //! On compare des MARGES, jamais des plafonds : le plus contraignant mord, et on sait diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index d4f4002..220d9ce 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 @@ -144,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) @@ -151,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; @@ -6108,6 +6115,262 @@ 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); + } + + // [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 +} + +void Simulation::testAutoProvisionedLabelIsCutNeverRefused() +{ +#ifndef ETM_ARBITRATOR + QSKIP("testAutoProvisionedLabelIsCutNeverRefused nécessite ETM_ARBITRATOR."); +#else + // [Q7, suite — relecture etm72, écart « important »] La borne prenait le nom de son Thing pour + // libellé et passait par l'écriture complète : un nom de plus de 256 caractères, ou un libellé + // hérité trop long sur une AUTRE entrée, refusait l'ajout, et la borne restait arbitrée sans + // rang réglable. Le nom est désormais coupé, et l'ajout ne borne que ce qu'il apporte. + const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-nomlong.json"; + QFile::remove(cfgPath); + qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8()); + const auto nettoyer = qScopeGuard([&] { + qunsetenv("NYMEA_ENERGY_LOAD_CONFIG"); + QFile::remove(cfgPath); + }); + // Une configuration HÉRITÉE dont une autre entrée porte un libellé de 300 caractères. + const QString libelleHerite(300, QLatin1Char('L')); + { + QFile f(cfgPath); + QVERIFY(f.open(QIODevice::WriteOnly | QIODevice::Text)); + f.write(QJsonDocument(QJsonObject{{"version", 1}, {"loads", QJsonArray{QJsonObject::fromVariantMap({ + {"id", "ballon-herite"}, {"label", libelleHerite}, {"adapter", "relay-router"}, {"mode", "fixed"}, + {"priority", 1}, {"enabled", false}, {"minOnS", 0}, {"minOffS", 0}, + {"relays", QVariantList{QVariantMap{{"thingId", "{aaaaaaaa-0000-4000-8000-0000000000e1}"}, + {"powerW", 1500}}}}})}}}).toJson()); + } + cleanupTestCase(); + m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite"; + initTestCase(); + + EnergyArbitrator *arb = dynamic_cast(m_experiencePlugin->smartChargingManager()); + QVERIFY(arb); + ThingManager *tm = NymeaCore::instance()->thingManager(); + QUuid meterId = addMeter(); + m_experiencePlugin->energyManager()->setRootMeter(meterId); + Thing *meter = tm->findConfiguredThing(meterId); + QVERIFY(meter); + meter->setStateValue("connected", true); + meter->setStateValue("currentPower", -4000); + + QUuid borneId = addCharger("A", 16, 26657); + Thing *borne = tm->findConfiguredThing(borneId); + QVERIFY(borne); + // 300 caractères, un emoji en 256e position : couper en unités UTF-16 le briserait en deux. + const QString prise = QString::fromUcs4(U"\U0001F50C"); + const QString nom = QString(255, QLatin1Char('a')) + prise + QString(44, QLatin1Char('b')); + QCOMPARE(nom.toUcs4().size(), qsizetype(300)); + borne->setName(nom); + armerBorne(borne); + + arb->simulationCallUpdate(utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0))); + QCoreApplication::processEvents(); + + const QHash cfg = configsParId(injectAndWait("NymeaEnergy.GetLoadConfig")); + QVERIFY2(cfg.contains(borneId.toString()), "la borne au nom de 300 caractères n'a pas été ajoutée"); + const QString libelle = cfg.value(borneId.toString()).value("label").toString(); + QCOMPARE(libelle, QString(255, QLatin1Char('a')) + prise); // la paire est ENTIÈRE + QCOMPARE(libelle.toUcs4().size(), qsizetype(LoadConfigStore::MaxLabelChars)); + QCOMPARE(cfg.value(borneId.toString()).value("rankOrigin").toString(), QStringLiteral("auto")); + QCOMPARE(cfg.value(borneId.toString()).value("priority").toInt(), 2); // en queue + // L'entrée héritée est intacte : le moteur n'a rien « réparé » d'office. + QCOMPARE(cfg.value("ballon-herite").value("label").toString(), libelleHerite); + + // CONTRE-ÉPREUVE en place : le libellé hérité dépasse BIEN la borne — l'écho client de ce + // même ensemble est refusé, sur lui. C'est l'ajout du moteur qui en est exempté, pas l'hérité + // qui serait passé inaperçu. + const QVariantList ensemble = injectAndWait("NymeaEnergy.GetLoadConfig").toMap() + .value("params").toMap().value("loadConfigs").toList(); + const QVariantMap p = injectAndWait("NymeaEnergy.SetLoadConfig", {{"loadConfigs", ensemble}}) + .toMap().value("params").toMap(); + QCOMPARE(p.value("energyError").toString(), QStringLiteral("EnergyErrorInvalidParameter")); + QCOMPARE(p.value("faultParams").toMap().value("cause").toString(), QStringLiteral("LABEL")); + QCOMPARE(p.value("faultParams").toMap().value("id").toString(), QStringLiteral("ballon-herite")); +#endif +} + void Simulation::testVanishedRelayFreezesTheLoad() { #ifndef ETM_ARBITRATOR diff --git a/tests/auto/simulation/simulation.h b/tests/auto/simulation/simulation.h index 1145cfc..edfacb8 100644 --- a/tests/auto/simulation/simulation.h +++ b/tests/auto/simulation/simulation.h @@ -250,6 +250,15 @@ 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(); + + // [Q7, suite — relecture etm72] Une borne au nom de 300 caractères est AJOUTÉE, son libellé + // coupé à 256 caractères sans couper de paire de substitution ; un libellé hérité trop long + // sur une autre entrée ne bloque pas l'ajout. + void testAutoProvisionedLabelIsCutNeverRefused(); + // [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 diff --git a/tests/auto/socketscheduler/testsocketscheduler.cpp b/tests/auto/socketscheduler/testsocketscheduler.cpp index 3a84fe3..f978a17 100644 --- a/tests/auto/socketscheduler/testsocketscheduler.cpp +++ b/tests/auto/socketscheduler/testsocketscheduler.cpp @@ -13,6 +13,8 @@ #include "energysettings.h" #include "etm/energyarbitrator.h" +#include "etm/config/loadconfigstore.h" +#include "etm/scheduler/optimizerclient.h" #include "etm/scheduler/socketscheduler.h" #include "etm/scheduler/optimizerstatus.h" #include "etm/scheduler/publishedplan.h" @@ -24,6 +26,7 @@ #include #include #include +#include #include #include @@ -127,6 +130,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(); @@ -139,6 +144,8 @@ private slots: void testTokenIsInNoTrace(); void testTraceLineSize(); void testTracerPairsTheResponseByRequestId(); + //! Q7, suite : une requête trop longue pour partir n'est tracée que par sa TAILLE. + void testARequestNotSentIsTracedBySizeOnly(); // Brief 4 T7 — correctif A : un token recopié par l'optimiseur ne sort nulle part void testAnEchoedTokenLeaksNowhere(); // Lot contrat OpenRPC — le moteur parle le contrat qu'il sert @@ -182,6 +189,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 +1575,121 @@ QList 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 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::testARequestNotSentIsTracedBySizeOnly() +{ + // Q7, suite : la trace recopiait la requête du cycle, même quand le client ne l'avait PAS + // écrite (ligne de plus de 1 Mio, request-too-long) — environ 1 Mio par cycle jusqu'au plafond + // journalier, pour une requête que personne n'a reçue. Seule sa TAILLE y figure désormais. + monter(); + const QString d = activerTraces(); + m_repondre = [](const QVariantMap &) { return QVariantMap{{"abstain", true}, {"reason", "test"}}; }; + const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)); + cycle(t0, -3000); // CONTRE-ÉPREUVE : une requête envoyée est recopiée + const int recues = m_requetes.count(); + + // Une configuration héritée au libellé démesuré : SetLoadConfig la refuserait, le chargement + // la garde, et le contexte dépasse la ligne du protocole. + const QString demesure(OptimizerClient::kMaxLineBytes + 1000, QLatin1Char('L')); + const QVariantList charges{ + QVariantMap{{"id", "ecs"}, {"label", "ECS"}, {"adapter", "relay-router"}, {"mode", "fixed"}, + {"priority", 1}, {"enabled", true}, {"minOnS", 0}, {"minOffS", 0}, + {"relays", QVariantList{QVariantMap{{"thingId", m_relais.toString()}, {"powerW", 1000}}}}}, + QVariantMap{{"id", m_var}, {"label", demesure}, {"adapter", "etmvariableload"}, {"mode", "dynamic"}, + {"maxPowerW", 3000}, {"priority", 2}, {"enabled", true}}}; + chargerConfigHeritee(charges); + m_compteur->setStateValue("currentPower", -3000); + m_arb->recordMeterUpdate(t0.addSecs(60)); + m_arb->simulationCallUpdate(t0.addSecs(60)); + QVERIFY(waitFor([&] { return m_codesEchec.contains(QStringLiteral("request-too-long")); })); + settle(); + QCOMPARE(m_requetes.count(), recues); // rien n'est parti + + QList l = traces(d, 2); + QCOMPARE(l.count(), 2); + QVERIFY(l.at(0).contains("request")); + QVERIFY(!l.at(0).contains("requestBytes")); + QVERIFY(!l.at(1).contains("request")); + const qint64 octets = l.at(1).value("requestBytes").toLongLong(); + QVERIFY2(octets > OptimizerClient::kMaxLineBytes && octets < 2 * OptimizerClient::kMaxLineBytes, + qPrintable(QString::number(octets))); + QCOMPARE(l.at(1).value("response").toMap(), + (QVariantMap{{"error", QVariantMap{{"code", "request-too-long"}}}})); + // Le libellé n'est NULLE PART dans la trace : le fichier entier est loin du mégaoctet. + qint64 taille = 0; + for (const QString &f : fichiersTrace(d)) + taille += QFileInfo(QDir(d).filePath(f)).size(); + QVERIFY2(taille < 200 * 1024, qPrintable(QString::number(taille))); + + // Sans optimiseur : la requête qui SERAIT partie suit la même règle. + monter(false); + const QString d2 = activerTraces(); + chargerConfigHeritee(charges); + cycle(t0.addSecs(120), -3000); + l = traces(d2, 1); + QCOMPARE(l.count(), 1); + QVERIFY(!l.at(0).contains("request")); + QVERIFY(l.at(0).value("requestBytes").toLongLong() > OptimizerClient::kMaxLineBytes); + QCOMPARE(l.at(0).value("strategy").toString(), QStringLiteral("rules")); + QVERIFY(l.at(0).value("response").toMap().contains("result")); +} + void TestSocketScheduler::testARequestTooLongCountsAsAFailure() { // Protocole 1.2, Q7 : une requête que le client n'émet pas (ligne de plus de 1 Mio) est un