diff --git a/energyplugin/etm/config/loadconfigstore.cpp b/energyplugin/etm/config/loadconfigstore.cpp index 6fb7b59..6b9f2a0 100644 --- a/energyplugin/etm/config/loadconfigstore.cpp +++ b/energyplugin/etm/config/loadconfigstore.cpp @@ -89,6 +89,15 @@ bool LoadConfigStore::load() << "mais il n'est PAS réglable tant que les rangs sont à égalité : il obéit à" << "des identifiants, donc à personne. À réparer par un SetLoadConfig."; } + // 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. + { + QString pourquoi; + if (!validateLabels(loaded, &pourquoi)) + qCWarning(dcNymeaEnergy()).noquote() + << "[LoadConfigStore] CONFIGURATION CONSERVÉE, LIBELLÉ TROP LONG :" << pourquoi + << "— chargé tel quel ; toute écriture sera refusée tant qu'il n'est pas raccourci."; + } m_configs = loaded; return true; @@ -196,8 +205,27 @@ bool LoadConfigStore::reconcileRankOrigin(const LoadConfigs &incoming, LoadConfi return true; } +bool LoadConfigStore::validateLabels(const LoadConfigs &configs, QString *error) +{ + for (const LoadConfig &c : configs) { + 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); + return false; + } + } + return true; +} + bool LoadConfigStore::setConfigs(const LoadConfigs &configs, QString *error) { + // 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)) + return false; + // Validation EN BLOC : une seule entrée invalide → rejet total (rien persisté). for (const LoadConfig &c : configs) { QString why; diff --git a/energyplugin/etm/config/loadconfigstore.h b/energyplugin/etm/config/loadconfigstore.h index 9bdac6d..817defc 100644 --- a/energyplugin/etm/config/loadconfigstore.h +++ b/energyplugin/etm/config/loadconfigstore.h @@ -86,6 +86,27 @@ public: */ static bool validateRanks(const LoadConfigs &configs, QString *error = nullptr); + //! Plus long libellé de charge accepté à l'écriture, en caractères (points de code Unicode). + static constexpr int MaxLabelChars = 256; + + /*! + * \brief Borne la longueur des libellés, à l'ÉCRITURE seulement (protocole optimiseur 1.2, Q7). + * + * Le libellé part tel quel dans chaque \c GetPlan (§5.3). Sans borne, une saisie démesurée + * ferait dépasser la limite de 1 Mio par ligne du protocole, et l'optimiseur ne recevrait plus + * rien. Le client d'optimiseur refuse d'émettre une telle ligne (\c request-too-long) : cette + * borne-ci arrête la saisie avant qu'elle n'y arrive. + * + * \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é. + * \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. + */ + static bool validateLabels(const LoadConfigs &configs, QString *error = nullptr); + /*! * \brief Réconcilie \c rankOrigin d'une écriture CLIENTE avec l'état persisté. LM-1209-c. * diff --git a/tests/auto/loadmodel/testloadmodel.cpp b/tests/auto/loadmodel/testloadmodel.cpp index 471b5d7..84906d3 100644 --- a/tests/auto/loadmodel/testloadmodel.cpp +++ b/tests/auto/loadmodel/testloadmodel.cpp @@ -471,6 +471,66 @@ void TestLoadModel::testRankUniquenessRefusesWriteButNeverDiscards() QCOMPARE(relu.configs().count(), 2); } +void TestLoadModel::testLabelIsBoundedOnWriteNeverOnLoad() +{ + // Protocole optimiseur 1.2, Q7 : le libellé part tel quel dans chaque GetPlan. Borné à + // l'ÉCRITURE (256 caractères), jamais au chargement — même partage que les rangs (LM-1209-a) : + // une règle resserrée après coup refuse une écriture, elle ne fait pas disparaître une charge. + const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-labels.json"; + QFile::remove(cfgPath); + qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8()); + QString err; + + // La borne compte des CARACTÈRES : 256 « é » (deux octets UTF-8 chacun) passent, et un + // caractère hors plan de base (deux unités UTF-16) compte pour un. + auto avecLibelle = [](const QString &label) { + QVariantMap m = borneMap(BORNE, 1); + m.insert("label", label); + return LoadConfigs(QList{LoadConfig::fromMap(m)}); + }; + const QString limite = QString(LoadConfigStore::MaxLabelChars - 1, QChar(0x00e9)) + QString::fromUcs4(U"\U0001F50C"); + QCOMPARE(limite.toUcs4().size(), qsizetype(256)); + QVERIFY(limite.size() > 256); // en unités UTF-16, il dépasserait : c'est bien le caractère qui compte + + LoadConfigStore store; + QVERIFY2(store.setConfigs(avecLibelle(limite), &err), qUtf8Printable(err)); + QCOMPARE(store.configs().at(0).label(), limite); + QFile f(cfgPath); + QVERIFY(f.open(QIODevice::ReadOnly)); + const QByteArray persiste = f.readAll(); + f.close(); + + // 257 : refusé, RIEN n'est modifié — ni en mémoire, ni sur disque. Le motif nomme la charge + // par son identifiant et donne la longueur, jamais le libellé. + const QString tropLong = QString(LoadConfigStore::MaxLabelChars + 1, QLatin1Char('L')); + QVERIFY(!store.setConfigs(avecLibelle(tropLong), &err)); + QVERIFY2(err.contains(QStringLiteral("257 caractères")) && err.contains(BORNE), qUtf8Printable(err)); + QVERIFY(!err.contains(tropLong.left(32))); + QCOMPARE(store.configs().count(), 1); + QCOMPARE(store.configs().at(0).label(), limite); + QVERIFY(f.open(QIODevice::ReadOnly)); + QCOMPARE(f.readAll(), persiste); + f.close(); + + // ---- Au CHARGEMENT : une configuration héritée garde son libellé, entier ---------------- + QVariantMap herite = borneMap(BORNE, 1); + herite.insert("label", tropLong); + QVERIFY(f.open(QIODevice::WriteOnly | QIODevice::Text)); + f.write(QJsonDocument(QJsonObject{{"version", 1}, {"loads", QJsonArray{QJsonObject::fromVariantMap(herite)}}}).toJson()); + f.close(); + LoadConfigStore relu; + QCOMPARE(relu.configs().count(), 1); + QCOMPARE(relu.configs().at(0).label(), tropLong); // ni écarté, ni tronqué + + // L'aller-retour verbatim est refusé tant que le libellé n'est pas raccourci ; raccourci, il passe. + QVERIFY(!relu.setConfigs(relu.configs(), &err)); + QCOMPARE(relu.configs().at(0).label(), tropLong); + LoadConfigs repare = relu.configs(); + repare[0].setLabel(QStringLiteral("Borne garage")); + QVERIFY2(relu.setConfigs(repare, &err), qUtf8Printable(err)); + QFile::remove(cfgPath); +} + 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 711f4dc..6afbfce 100644 --- a/tests/auto/loadmodel/testloadmodel.h +++ b/tests/auto/loadmodel/testloadmodel.h @@ -75,6 +75,8 @@ private slots: //! La marque de rang s'efface sur un RANG changé, pas sur une écriture reçue — et un //! client ne peut pas fabriquer un "auto" (LM-1209-c). void testRankOriginFollowsTheRankNotTheWrite(); + // Protocole optimiseur 1.2, Q7 — le libellé est borné à l'écriture, jamais au chargement + void testLabelIsBoundedOnWriteNeverOnLoad(); // ── LM-1006-1 — le plafond de soutirage, étape 1 ─────────────────────── //! On compare des MARGES, jamais des plafonds : le plus contraignant mord, et on sait