SetLoadConfig : motif de refus publié (LOAD_CONFIG_REFUSED), id et échéance bornés à l'écriture (Q7)
Le refus garde EnergyErrorInvalidParameter et gagne, comme SetTariff, un
`fault` LOAD_CONFIG_REFUSED et des `faultParams` : une cause par motif
existant (ID, LABEL, DEADLINE, MODE, DOMAIN, RANK_ORIGIN, RANK_ORIGIN_AUTO,
ADAPTER, FIELD_*, SG_READY_STATE_2, ID_DUPLICATE, THING_SHARED,
RANK_DUPLICATE, WRITE_FAILED, STORE_UNAVAILABLE) et l'endroit (index, id
quand il est bien formé, field, state, indexes/ids, rank, length/max,
position). Jamais le libellé. Les deux champs sont déclarés optionnels dans
le schéma ; un succès rend exactement ce qu'il rendait.
- id : 1 à 128 caractères de [A-Za-z0-9._{}-], refusé à l'écriture ;
hérité hors règle, chargé tel quel et signalé.
- needs.dailyDeadline : vide ou HH:MM (00:00-23:59), refusé à l'écriture ;
hérité illisible, l'entrée est chargée, l'échéance retirée de la mémoire
(ni l'arbitre ni l'optimiseur ne la reçoivent) et signalée.
- Les bornes de saisie passent avant la réconciliation de rankOrigin, dont le
motif cite le libellé.
- appendConfig() : l'ajout par le moteur ne borne que l'entrée ajoutée.
- leftChars() coupe en caractères sans couper une paire de substitution ;
l'id d'un motif n'est plus coupé par QString::left().
- Au chargement, chaque libellé trop long est signalé, pas seulement le
premier ; loadconfigstore.h dit « n'écarte rien au chargement ».
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
fae7101182
commit
53a10b3062
@ -13,6 +13,35 @@
|
|||||||
#include <QJsonObject>
|
#include <QJsonObject>
|
||||||
#include <QJsonArray>
|
#include <QJsonArray>
|
||||||
#include <QJsonParseError>
|
#include <QJsonParseError>
|
||||||
|
#include <QRegularExpression>
|
||||||
|
|
||||||
|
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("…");
|
||||||
|
}
|
||||||
|
|
||||||
|
//! 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)
|
LoadConfigStore::LoadConfigStore(QObject *parent)
|
||||||
: QObject{parent}
|
: QObject{parent}
|
||||||
@ -91,31 +120,60 @@ bool LoadConfigStore::load()
|
|||||||
}
|
}
|
||||||
// Q7 — même règle pour un libellé trop long : CHARGÉ TEL QUEL, jamais tronqué ni écarté. Le
|
// 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.
|
// 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.
|
||||||
|
for (int i = 0; i < loaded.count(); ++i) {
|
||||||
QString pourquoi;
|
QString pourquoi;
|
||||||
if (!validateLabels(loaded, &pourquoi))
|
if (!validateLabels(LoadConfigs(QList<LoadConfig>{loaded.at(i)}), &pourquoi))
|
||||||
qCWarning(dcNymeaEnergy()).noquote()
|
qCWarning(dcNymeaEnergy()).noquote()
|
||||||
<< "[LoadConfigStore] CONFIGURATION CONSERVÉE, LIBELLÉ TROP LONG :" << pourquoi
|
<< "[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(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(idPourMotif(loaded.at(i).id()), valeurPourMotif(echeance));
|
||||||
|
LoadConfigNeeds besoins = loaded.at(i).needs();
|
||||||
|
besoins.setDailyDeadline(QString());
|
||||||
|
loaded[i].setNeeds(besoins);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
m_configs = loaded;
|
m_configs = loaded;
|
||||||
return true;
|
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 (error) *error = why;
|
||||||
|
if (fault) *fault = motif;
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
QSet<QString> ids;
|
QSet<QString> ids;
|
||||||
QHash<QString, QString> proprietaire; // ThingId normalisé → libellé de la charge
|
QHash<QString, int> 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()))
|
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());
|
ids.insert(c.id());
|
||||||
|
|
||||||
if (!c.enabled())
|
if (!c.enabled())
|
||||||
@ -128,45 +186,56 @@ 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
|
// les autres — d'où ce contrôle d'ENSEMBLE, et d'où le fait qu'il soit distinct de
|
||||||
// isValid().
|
// isValid().
|
||||||
for (const QString &thing : c.claimedThingIds()) {
|
for (const QString &thing : c.claimedThingIds()) {
|
||||||
const QString deja = proprietaire.value(thing);
|
if (proprietaire.contains(thing)) {
|
||||||
if (!deja.isEmpty())
|
const int j = proprietaire.value(thing);
|
||||||
return fail(QStringLiteral("le Thing %1 est commandé par deux charges actives, "
|
return fail(QStringLiteral("le Thing %1 est commandé par deux charges actives, "
|
||||||
"\"%2\" et \"%3\" : deux commandes contradictoires et "
|
"\"%2\" et \"%3\" : deux commandes contradictoires et "
|
||||||
"une puissance comptée deux fois")
|
"une puissance comptée deux fois")
|
||||||
.arg(thing, deja, c.label()));
|
.arg(thing, configs.at(j).label(), c.label()),
|
||||||
proprietaire.insert(thing, 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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool LoadConfigStore::validateRanks(const LoadConfigs &configs, QString *error)
|
bool LoadConfigStore::validateRanks(const LoadConfigs &configs, QString *error, QVariantMap *fault)
|
||||||
{
|
{
|
||||||
QHash<int, QString> parRang;
|
QHash<int, int> parRang; // rang → index de la première charge qui le porte
|
||||||
for (const LoadConfig &c : configs) {
|
for (int i = 0; i < configs.count(); ++i) {
|
||||||
const QString deja = parRang.value(c.priority());
|
const LoadConfig &c = configs.at(i);
|
||||||
if (!deja.isEmpty()) {
|
if (parRang.contains(c.priority())) {
|
||||||
|
const int j = parRang.value(c.priority());
|
||||||
if (error)
|
if (error)
|
||||||
*error = QStringLiteral("les charges \"%1\" et \"%2\" portent toutes deux le rang "
|
*error = QStringLiteral("les charges \"%1\" et \"%2\" portent toutes deux le rang "
|
||||||
"%3 : leur ordre de service serait stable mais NON "
|
"%3 : leur ordre de service serait stable mais NON "
|
||||||
"RÉGLABLE — il se déciderait sur leurs identifiants")
|
"RÉGLABLE — il se déciderait sur leurs identifiants")
|
||||||
.arg(deja, c.label()).arg(c.priority());
|
.arg(configs.at(j).label(), 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;
|
return false;
|
||||||
}
|
}
|
||||||
parRang.insert(c.priority(), c.label());
|
parRang.insert(c.priority(), i);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool LoadConfigStore::reconcileRankOrigin(const LoadConfigs &incoming, LoadConfigs *out,
|
bool LoadConfigStore::reconcileRankOrigin(const LoadConfigs &incoming, LoadConfigs *out,
|
||||||
QString *error) const
|
QString *error, QVariantMap *fault) const
|
||||||
{
|
{
|
||||||
QHash<QString, const LoadConfig *> stocke;
|
QHash<QString, const LoadConfig *> stocke;
|
||||||
for (const LoadConfig &c : m_configs)
|
for (const LoadConfig &c : m_configs)
|
||||||
stocke.insert(c.id(), &c);
|
stocke.insert(c.id(), &c);
|
||||||
|
|
||||||
LoadConfigs resultat;
|
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 LoadConfig *avant = stocke.value(c.id(), nullptr);
|
||||||
const QString marqueAvant = avant ? avant->rankOrigin() : QString();
|
const QString marqueAvant = avant ? avant->rankOrigin() : QString();
|
||||||
|
|
||||||
@ -180,6 +249,8 @@ bool LoadConfigStore::reconcileRankOrigin(const LoadConfigs &incoming, LoadConfi
|
|||||||
"moteur n'a pas posé : c'est une affirmation sur un geste "
|
"moteur n'a pas posé : c'est une affirmation sur un geste "
|
||||||
"du moteur, elle ne s'écrit pas depuis un client")
|
"du moteur, elle ne s'écrit pas depuis un client")
|
||||||
.arg(c.label());
|
.arg(c.label());
|
||||||
|
if (fault)
|
||||||
|
*fault = motifEntree("RANK_ORIGIN_AUTO", i, c.id());
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -205,42 +276,155 @@ bool LoadConfigStore::reconcileRankOrigin(const LoadConfigs &incoming, LoadConfi
|
|||||||
return true;
|
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();
|
const qsizetype caracteres = c.label().toUcs4().size();
|
||||||
if (caracteres > MaxLabelChars) {
|
if (caracteres > MaxLabelChars) {
|
||||||
if (error)
|
if (error)
|
||||||
*error = QStringLiteral("le libellé de la charge \"%1\" compte %2 caractères, %3 au plus")
|
*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 false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true;
|
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é
|
// Le jeu est ASCII : un caractère y vaut une unité UTF-16, et size() compte des caractères.
|
||||||
// démesuré ne doit entrer ni dans la configuration ni dans le journal (Q7).
|
static const QRegularExpression forme(
|
||||||
if (!validateLabels(configs, error))
|
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<uint> points = texte.toUcs4();
|
||||||
|
if (points.size() <= n)
|
||||||
|
return texte;
|
||||||
|
return QString::fromUcs4(reinterpret_cast<const char32_t *>(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;
|
return false;
|
||||||
|
|
||||||
// Validation EN BLOC : une seule entrée invalide → rejet total (rien persisté).
|
// 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;
|
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 (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;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// …puis la cohérence d'ensemble, qu'aucune charge ne peut vérifier seule.
|
// …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;
|
return false;
|
||||||
|
|
||||||
// …puis la BONNE FORME, qui ne s'applique qu'ici. load() ne l'appelle pas : elle refuse
|
// …puis la BONNE FORME, qui ne s'applique qu'ici. load() n'écarte rien sur sa foi : elle
|
||||||
// une écriture, elle n'écarte jamais une entrée déjà persistée (LM-1209-a).
|
// refuse une écriture, elle n'écarte jamais une entrée déjà persistée (LM-1209-a).
|
||||||
if (!validateRanks(configs, error))
|
if (!validateRanks(configs, error, fault))
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
const LoadConfigs previous = m_configs;
|
const LoadConfigs previous = m_configs;
|
||||||
@ -248,6 +432,8 @@ bool LoadConfigStore::setConfigs(const LoadConfigs &configs, QString *error)
|
|||||||
|
|
||||||
if (!save(error)) {
|
if (!save(error)) {
|
||||||
m_configs = previous; // rollback mémoire si l'écriture échoue
|
m_configs = previous; // rollback mémoire si l'écriture échoue
|
||||||
|
if (fault)
|
||||||
|
*fault = QVariantMap{{QStringLiteral("cause"), QStringLiteral("WRITE_FAILED")}};
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -41,9 +41,32 @@ public:
|
|||||||
* \brief Remplace l'ensemble des configs : valide tout → persiste → \c emit changed().
|
* \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 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] 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.
|
* \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.
|
/*! \brief Cohérence de l'ENSEMBLE, ce qu'aucune charge ne peut vérifier seule.
|
||||||
*
|
*
|
||||||
@ -61,14 +84,18 @@ public:
|
|||||||
*
|
*
|
||||||
* \param configs Ensemble complet à confronter (les charges désactivées sont ignorées).
|
* \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.
|
||||||
|
* \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é.
|
* \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.
|
* \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.
|
||||||
|
* \param[out] fault Si refus : \c RANK_DUPLICATE (\c indexes, \c ids, \c rank).
|
||||||
* \return Faux si deux charges portent le même rang.
|
* \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é**
|
* \note **Distincte de \c validateSet(), et la distinction est de sûreté, pas de propreté**
|
||||||
@ -81,10 +108,11 @@ public:
|
|||||||
* héritée à rangs doublés : des charges disparaîtraient de la configuration ET de
|
* 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
|
* l'arbitrage, sans \c applySafeState — une règle de contrat convertie en perte de
|
||||||
* configuration silencieuse.
|
* 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.
|
* 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).
|
//! Plus long libellé de charge accepté à l'écriture, en caractères (points de code Unicode).
|
||||||
static constexpr int MaxLabelChars = 256;
|
static constexpr int MaxLabelChars = 256;
|
||||||
@ -99,19 +127,99 @@ public:
|
|||||||
*
|
*
|
||||||
* \param configs Ensemble candidat. \param[out] error Motif du refus, en français ; il nomme
|
* \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é.
|
* 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.
|
* \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
|
* \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
|
* un écart au chargement. \c load() n'écarte rien sur sa foi — un libellé plus long déjà
|
||||||
* **chargé tel quel, signalé, jamais tronqué** ; la première écriture devra le raccourcir.
|
* 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.
|
* \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 incoming Ensemble reçu du client. \param error Motif du refus, si refus.
|
||||||
* \param[out] out Ensemble à persister, \c rankOrigin réconcilié.
|
* \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".
|
* \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 :
|
* Trois règles, et la deuxième est celle qu'on se serait trompé à écrire :
|
||||||
@ -129,7 +237,7 @@ public:
|
|||||||
* seule voie, et elle est explicite. L'omission ne lève rien (règle 2).
|
* seule voie, et elle est explicite. L'omission ne lève rien (règle 2).
|
||||||
*/
|
*/
|
||||||
bool reconcileRankOrigin(const LoadConfigs &incoming, LoadConfigs *out,
|
bool reconcileRankOrigin(const LoadConfigs &incoming, LoadConfigs *out,
|
||||||
QString *error = nullptr) const;
|
QString *error = nullptr, QVariantMap *fault = nullptr) const;
|
||||||
|
|
||||||
signals:
|
signals:
|
||||||
/*! \brief Émis après une persistance réussie de \c setConfigs(). */
|
/*! \brief Émis après une persistance réussie de \c setConfigs(). */
|
||||||
@ -139,6 +247,8 @@ private:
|
|||||||
QString filePath() const;
|
QString filePath() const;
|
||||||
bool load();
|
bool load();
|
||||||
bool save(QString *error) const;
|
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;
|
LoadConfigs m_configs;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -272,6 +272,39 @@ QString renderFr(const DecisionReason &reason, const QString &label)
|
|||||||
ou.isEmpty() ? QString() : QStringLiteral(" : ") + ou.join(QStringLiteral(", ")));
|
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))
|
if (c == QLatin1String(DecisionCode::SpotMarketNotInBuild))
|
||||||
// LE GESTE : aucun réglage ne l'active, c'est le paquet qui ne le contient pas.
|
// 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 "
|
return QStringLiteral("Marché spot non disponible dans ce build — aucun fournisseur "
|
||||||
@ -354,7 +387,7 @@ QStringList DecisionCode::knownCodes()
|
|||||||
EcoFloorGrid, EcoFloorBelowStage, EcoFloorMet, EcoFloorMissed, RecreditSuspended,
|
EcoFloorGrid, EcoFloorBelowStage, EcoFloorMet, EcoFloorMissed, RecreditSuspended,
|
||||||
DerogationActive,
|
DerogationActive,
|
||||||
DerogationEnded, MissingForecastSite, ForecastPayloadRefused, SpotMarketNotInBuild,
|
DerogationEnded, MissingForecastSite, ForecastPayloadRefused, SpotMarketNotInBuild,
|
||||||
ForecastDateUnreadable, TariffRefused,
|
ForecastDateUnreadable, TariffRefused, LoadConfigRefused,
|
||||||
LockMinStateHold, SgForced, SgRecommended, SgNormal,
|
LockMinStateHold, SgForced, SgRecommended, SgNormal,
|
||||||
OptimizerDecision, PriceSlot,
|
OptimizerDecision, PriceSlot,
|
||||||
DegradedL2, SafeStateRelay, SafeStateSetpoint, SafeStateSgReady};
|
DegradedL2, SafeStateRelay, SafeStateSetpoint, SafeStateSgReady};
|
||||||
|
|||||||
@ -374,6 +374,27 @@ constexpr const char *ForecastDateUnreadable = "FORECAST_DATE_UNREADABLE";
|
|||||||
*/
|
*/
|
||||||
constexpr const char *TariffRefused = "TARIFF_REFUSED";
|
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) ---------------------------------------------------------
|
// --- SG-Ready (PAC) ---------------------------------------------------------
|
||||||
//! params: \c state — le verrou minStateHold maintient l'état (court-cycling compresseur).
|
//! params: \c state — le verrou minStateHold maintient l'état (court-cycling compresseur).
|
||||||
constexpr const char *LockMinStateHold = "LOCK_MIN_STATE_HOLD";
|
constexpr const char *LockMinStateHold = "LOCK_MIN_STATE_HOLD";
|
||||||
|
|||||||
@ -194,17 +194,31 @@ QSet<QString> LoadConfig::claimedThingIds() const
|
|||||||
return ids;
|
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".
|
// Validation CONDITIONNELLE au type (contrat rév. 3) — pas "tous champs requis".
|
||||||
// Les deux formes (relay-router / etmvariableload) sont mutuellement exclusives ; le schéma
|
// 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.
|
// JSON-RPC SET marque TOUS les champs spécifiques en optionnel, c'est ICI qu'on tranche.
|
||||||
if (m_id.isEmpty())
|
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"))
|
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
|
// 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
|
// 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
|
// l'énumération : une valeur libre acceptée aujourd'hui deviendrait une valeur à supporter
|
||||||
// le jour où la couche domaine en fera son discriminant.
|
// le jour où la couche domaine en fera son discriminant.
|
||||||
if (!m_domain.isEmpty() && !knownDomains().contains(m_domain))
|
if (!m_domain.isEmpty() && !knownDomains().contains(m_domain))
|
||||||
return fail(QStringLiteral("domain \"%1\" hors énumération (attendu : %2, ou absent "
|
return fail("DOMAIN", QStringLiteral("domain \"%1\" hors énumération (attendu : %2, ou absent "
|
||||||
"pour une charge non classée)")
|
"pour une charge non classée)")
|
||||||
.arg(m_domain, knownDomains().join(QStringLiteral(", "))));
|
.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
|
// 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
|
// question n'a de sens que face à l'état PERSISTÉ, et elle est donc tranchée à la
|
||||||
// frontière d'écriture (LoadConfigStore::reconcileRankOrigin).
|
// frontière d'écriture (LoadConfigStore::reconcileRankOrigin).
|
||||||
if (!m_rankOrigin.isEmpty() && !knownRankOrigins().contains(m_rankOrigin))
|
if (!m_rankOrigin.isEmpty() && !knownRankOrigins().contains(m_rankOrigin))
|
||||||
return fail(QStringLiteral("rankOrigin \"%1\" hors énumération (attendu : %2, ou absent "
|
return fail("RANK_ORIGIN", QStringLiteral("rankOrigin \"%1\" hors énumération (attendu : %2, ou absent "
|
||||||
"— l'absence n'affirme rien)")
|
"— l'absence n'affirme rien)")
|
||||||
.arg(m_rankOrigin, knownRankOrigins().join(QStringLiteral(", "))));
|
.arg(m_rankOrigin, knownRankOrigins().join(QStringLiteral(", "))));
|
||||||
|
|
||||||
// LM-300 — UNION DISCRIMINÉE par mécanisme. Le discriminant est \c adapter ; chaque
|
// 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 aRelays = !m_relays.isEmpty();
|
||||||
const bool aSgReady = !m_sgReady.states.isEmpty();
|
const bool aSgReady = !m_sgReady.states.isEmpty();
|
||||||
const bool aLevels = !m_powerLevels.isEmpty() || m_maxPowerW > 0;
|
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 (m_adapter == QStringLiteral("sg-ready")) {
|
||||||
if (aRelays)
|
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)
|
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)
|
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"))
|
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)
|
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<int> vus;
|
QList<int> vus;
|
||||||
for (const LoadConfigSgReadyState &e : m_sgReady.states) {
|
for (const LoadConfigSgReadyState &e : m_sgReady.states) {
|
||||||
if (e.state < 1 || e.state > 4)
|
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))
|
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);
|
vus.append(e.state);
|
||||||
if (e.estimatedPowerW < 0)
|
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)
|
for (const QString &r : e.relays)
|
||||||
if (r.isEmpty())
|
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<QString> vusEtat;
|
QSet<QString> vusEtat;
|
||||||
for (const QString &r : e.relays) {
|
for (const QString &r : e.relays) {
|
||||||
// Un même relais dans PLUSIEURS états est normal (état 4 = état 1 + état 3) ;
|
// 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.
|
// deux fois dans le MÊME état ne l'est pas.
|
||||||
if (vusEtat.contains(normalizedThingId(r)))
|
if (vusEtat.contains(normalizedThingId(r)))
|
||||||
return fail(QStringLiteral("sg-ready : état %1 déclare deux fois le Thing %2")
|
return fail("FIELD_DUPLICATE", QStringLiteral("sg-ready : état %1 déclare deux fois le Thing %2")
|
||||||
.arg(e.state).arg(r));
|
.arg(e.state).arg(r), QStringLiteral("sgReady.states.relays"), e.state);
|
||||||
vusEtat.insert(normalizedThingId(r));
|
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
|
// 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.
|
// construite : c'est le garde-fou qui remplace le Q_ASSERT disparu en release.
|
||||||
if (!m_sgReady.hasState(2))
|
if (!m_sgReady.hasState(2))
|
||||||
return fail(QStringLiteral("sg-ready : l'état 2 (normal) est OBLIGATOIRE — c'est le "
|
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 "
|
"repli sûr du mode dégradé L2, sans lui la charge ne peut "
|
||||||
"pas être mise en sécurité"));
|
"pas être mise en sécurité"));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (aSgReady)
|
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")) {
|
if (m_adapter == QStringLiteral("evcharger")) {
|
||||||
// 3g-2 — la borne n'apporte QUE son rang. Ses limites de courant, son nombre de phases
|
// 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
|
// 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.
|
// ferait une seconde source de vérité, périmée dès la première voiture branchée.
|
||||||
if (aRelays)
|
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)
|
if (aLevels)
|
||||||
return fail(QStringLiteral("evcharger : powerLevels/maxPowerW interdits — les limites "
|
return fail("FIELD_FORBIDDEN", QStringLiteral("evcharger : powerLevels/maxPowerW interdits — les limites "
|
||||||
"de la borne viennent du Thing, pas de la configuration"));
|
"de la borne viennent du Thing, pas de la configuration"), champNiveaux);
|
||||||
if (m_minPowerW > 0)
|
if (m_minPowerW > 0)
|
||||||
return fail(QStringLiteral("evcharger : minPowerW interdit — le plancher est dérivé du "
|
return fail("FIELD_FORBIDDEN", QStringLiteral("evcharger : minPowerW interdit — le plancher est dérivé du "
|
||||||
"courant minimal de la borne et du véhicule branché"));
|
"courant minimal de la borne et du véhicule branché"),
|
||||||
|
QStringLiteral("minPowerW"));
|
||||||
if (m_minOnS != 0 || m_minOffS != 0)
|
if (m_minOnS != 0 || m_minOffS != 0)
|
||||||
return fail(QStringLiteral("evcharger : minOnS/minOffS interdits — les verrous d'une "
|
return fail("FIELD_FORBIDDEN", QStringLiteral("evcharger : minOnS/minOffS interdits — les verrous d'une "
|
||||||
"borne vivent dans le manager amont"));
|
"borne vivent dans le manager amont"), champVerrous);
|
||||||
// Une borne module en continu : le mode le dit, comme pour tout autre mécanisme. On
|
// 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.
|
// garde le champ plutôt que de l'exempter, pour que la forme de l'union reste UNE.
|
||||||
if (m_mode != QStringLiteral("dynamic"))
|
if (m_mode != QStringLiteral("dynamic"))
|
||||||
return fail(QStringLiteral("evcharger : mode doit être \"dynamic\" (une borne module, "
|
return fail("MODE", QStringLiteral("evcharger : mode doit être \"dynamic\" (une borne module, "
|
||||||
"elle n'a pas de paliers)"));
|
"elle n'a pas de paliers)"));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (m_adapter == QStringLiteral("relay-router")) {
|
if (m_adapter == QStringLiteral("relay-router")) {
|
||||||
// Cas relais (rév. 3) : liste de relais power. powerLevels/maxPowerW DÉRIVÉS (ignorés ici).
|
// Cas relais (rév. 3) : liste de relais power. powerLevels/maxPowerW DÉRIVÉS (ignorés ici).
|
||||||
if (m_mode != QStringLiteral("fixed"))
|
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())
|
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<QString> vus;
|
QSet<QString> vus;
|
||||||
for (const LoadConfigRelay &r : m_relays) {
|
for (const LoadConfigRelay &r : m_relays) {
|
||||||
if (r.thingId.isEmpty())
|
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)
|
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
|
// 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.
|
// silencieusement, et la puissance annoncée serait le double de la réelle.
|
||||||
if (vus.contains(normalizedThingId(r.thingId)))
|
if (vus.contains(normalizedThingId(r.thingId)))
|
||||||
return fail(QStringLiteral("relay-router : le Thing %1 est déclaré sur deux étages")
|
return fail("FIELD_DUPLICATE", QStringLiteral("relay-router : le Thing %1 est déclaré sur deux étages")
|
||||||
.arg(r.thingId));
|
.arg(r.thingId), QStringLiteral("relays.thingId"));
|
||||||
vus.insert(normalizedThingId(r.thingId));
|
vus.insert(normalizedThingId(r.thingId));
|
||||||
}
|
}
|
||||||
if (m_minOnS < 0 || m_minOffS < 0)
|
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
|
// §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[].
|
// plus petit palier atteignable, donc il se dérive de relays[].
|
||||||
if (m_minPowerW > 0)
|
if (m_minPowerW > 0)
|
||||||
return fail(QStringLiteral("relay-router : minPowerW interdit — le plancher est le "
|
return fail("FIELD_FORBIDDEN", QStringLiteral("relay-router : minPowerW interdit — le plancher est le "
|
||||||
"plus petit palier dérivé des relais"));
|
"plus petit palier dérivé des relais"), QStringLiteral("minPowerW"));
|
||||||
|
|
||||||
} else if (m_adapter == QStringLiteral("etmvariableload")) {
|
} else if (m_adapter == QStringLiteral("etmvariableload")) {
|
||||||
// Cas thing unique : continu (dynamic) ou multipalier natif (fixed + powerLevels, legacy).
|
// Cas thing unique : continu (dynamic) ou multipalier natif (fixed + powerLevels, legacy).
|
||||||
if (!m_relays.isEmpty())
|
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_mode == QStringLiteral("fixed")) {
|
||||||
if (m_powerLevels.isEmpty())
|
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)
|
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
|
// §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
|
// 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.
|
// ne dirait laquelle fait foi le jour où elles divergent.
|
||||||
if (m_minPowerW > 0)
|
if (m_minPowerW > 0)
|
||||||
return fail(QStringLiteral("etmvariableload fixed : minPowerW interdit — le "
|
return fail("FIELD_FORBIDDEN", QStringLiteral("etmvariableload fixed : minPowerW interdit — le "
|
||||||
"plancher est le plus petit palier non nul"));
|
"plancher est le plus petit palier non nul"),
|
||||||
|
QStringLiteral("minPowerW"));
|
||||||
} else { // dynamic
|
} else { // dynamic
|
||||||
if (m_maxPowerW <= 0)
|
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())
|
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
|
// 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.
|
// {0} ∪ [min, max] serait réduit à {0}, et la charge ne démarrerait jamais.
|
||||||
if (m_minPowerW > static_cast<uint>(m_maxPowerW))
|
if (m_minPowerW > static_cast<uint>(m_maxPowerW))
|
||||||
return fail(QStringLiteral("etmvariableload dynamic : minPowerW (%1 W) dépasse "
|
return fail("FIELD_RANGE", QStringLiteral("etmvariableload dynamic : minPowerW (%1 W) dépasse "
|
||||||
"maxPowerW (%2 W) — la charge ne pourrait jamais démarrer")
|
"maxPowerW (%2 W) — la charge ne pourrait jamais démarrer")
|
||||||
.arg(m_minPowerW).arg(m_maxPowerW));
|
.arg(m_minPowerW).arg(m_maxPowerW), QStringLiteral("minPowerW"));
|
||||||
}
|
}
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
return fail(QStringLiteral("mécanisme inconnu \"%1\" (attendu \"relay-router\", "
|
return fail("ADAPTER", QStringLiteral("mécanisme inconnu \"%1\" (attendu \"relay-router\", "
|
||||||
"\"etmvariableload\", \"sg-ready\" ou \"evcharger\")").arg(m_adapter));
|
"\"etmvariableload\", \"sg-ready\" ou \"evcharger\")").arg(m_adapter));
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -379,9 +379,14 @@ public:
|
|||||||
/*!
|
/*!
|
||||||
* \brief Valide la cohérence (contrat §4/§6) : adapter, id, mode, paliers/plafond.
|
* \brief Valide la cohérence (contrat §4/§6) : adapter, id, mode, paliers/plafond.
|
||||||
* \param[out] error Message FR si invalide.
|
* \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.
|
* \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
|
//! \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.
|
//! que s'il existe au moins un relais ; \c powerLevels que s'il est non vide.
|
||||||
|
|||||||
@ -195,7 +195,13 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana
|
|||||||
|
|
||||||
params.clear(); returns.clear();
|
params.clear(); returns.clear();
|
||||||
description = "Replace the full set of controllable load configs. Rejected as a whole "
|
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<LoadConfig>) : le contrat rév. 3 a DEUX formes exclusives
|
// Schéma inline (pas objectRef<LoadConfig>) : le contrat rév. 3 a DEUX formes exclusives
|
||||||
// (relay-router : relays[]+minOnS/minOffS — etmvariableload : powerLevels|maxPowerW).
|
// (relay-router : relays[]+minOnS/minOffS — etmvariableload : powerLevels|maxPowerW).
|
||||||
// objectRef rendrait TOUS les champs requis → nymea rejetterait une des deux formes AVANT
|
// 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));
|
loadItem.insert("o:minOffS", enumValueName(Uint));
|
||||||
params.insert("loadConfigs", QVariantList() << loadItem);
|
params.insert("loadConfigs", QVariantList() << loadItem);
|
||||||
returns.insert("energyError", enumRef<EnergyManager::EnergyError>());
|
returns.insert("energyError", enumRef<EnergyManager::EnergyError>());
|
||||||
|
// 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);
|
registerMethod("SetLoadConfig", description, params, returns, Types::PermissionScopeConfigureThings);
|
||||||
|
|
||||||
// [Phase 2] Ratios canoniques autoconsommation / autonomie (% [0,100]). Champ omis
|
// [Phase 2] Ratios canoniques autoconsommation / autonomie (% [0,100]). Champ omis
|
||||||
@ -1139,30 +1149,46 @@ JsonReply *NymeaEnergyJsonHandler::GetLoadConfig(const QVariantMap ¶ms)
|
|||||||
|
|
||||||
JsonReply *NymeaEnergyJsonHandler::SetLoadConfig(const QVariantMap ¶ms)
|
JsonReply *NymeaEnergyJsonHandler::SetLoadConfig(const QVariantMap ¶ms)
|
||||||
{
|
{
|
||||||
if (!m_loadConfigStore) {
|
// Même forme que SetTariff : le code d'erreur amont, inchangé, et à côté le MOTIF publiable —
|
||||||
qCWarning(dcNymeaEnergy()) << "[NymeaEnergy] SetLoadConfig sans store configuré.";
|
// une cause et un endroit, jamais une phrase (règle 12). Le journal garde la phrase
|
||||||
return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorInvalidParameter)}});
|
// française rendue depuis ce motif, suivie du détail du refus.
|
||||||
}
|
auto refuser = [this](const QVariantMap &faultParams, const QString &detail) {
|
||||||
|
// 7-d — le refus au moins aussi haut que l'enregistrement.
|
||||||
|
qCWarning(dcNymeaEnergy()).noquote()
|
||||||
|
<< QStringLiteral("[NymeaEnergy] SetLoadConfig rejetée : %1 (motif %2)%3")
|
||||||
|
.arg(renderFr({DecisionCode::LoadConfigRefused, faultParams}),
|
||||||
|
QLatin1String(DecisionCode::LoadConfigRefused),
|
||||||
|
detail.isEmpty() ? QString() : QStringLiteral(" — ") + detail);
|
||||||
|
return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorInvalidParameter)},
|
||||||
|
{"fault", QLatin1String(DecisionCode::LoadConfigRefused)},
|
||||||
|
{"faultParams", faultParams}});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!m_loadConfigStore)
|
||||||
|
return refuser({{QStringLiteral("cause"), QStringLiteral("STORE_UNAVAILABLE")}},
|
||||||
|
QStringLiteral("aucun store configuré"));
|
||||||
|
|
||||||
LoadConfigs configs;
|
LoadConfigs configs;
|
||||||
foreach (const QVariant &item, params.value("loadConfigs").toList())
|
foreach (const QVariant &item, params.value("loadConfigs").toList())
|
||||||
configs.append(LoadConfig::fromMap(item.toMap()));
|
configs.append(LoadConfig::fromMap(item.toMap()));
|
||||||
|
|
||||||
QString why;
|
QString why;
|
||||||
|
QVariantMap faultParams;
|
||||||
|
// Q7 — les bornes de saisie AVANT tout le reste, réconciliation comprise : ses motifs citent
|
||||||
|
// l'identifiant et le libellé, qui doivent donc être bornés avant d'entrer au journal.
|
||||||
|
if (!LoadConfigStore::validateBounds(configs, &why, &faultParams))
|
||||||
|
return refuser(faultParams, why);
|
||||||
|
|
||||||
// LM-1209-c — la marque de rang se réconcilie ICI, à la frontière CLIENTE, et nulle part
|
// 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,
|
// 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.
|
// lui, pose légitimement "auto" en créant une entrée d'office.
|
||||||
LoadConfigs reconciliees;
|
LoadConfigs reconciliees;
|
||||||
if (!m_loadConfigStore->reconcileRankOrigin(configs, &reconciliees, &why)) {
|
if (!m_loadConfigStore->reconcileRankOrigin(configs, &reconciliees, &why, &faultParams))
|
||||||
qCWarning(dcNymeaEnergy()) << "[NymeaEnergy] SetLoadConfig rejetée:" << why;
|
return refuser(faultParams, why);
|
||||||
return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorInvalidParameter)}});
|
|
||||||
}
|
|
||||||
configs = reconciliees;
|
configs = reconciliees;
|
||||||
|
|
||||||
if (!m_loadConfigStore->setConfigs(configs, &why)) {
|
if (!m_loadConfigStore->setConfigs(configs, &why, &faultParams))
|
||||||
qCWarning(dcNymeaEnergy()) << "[NymeaEnergy] SetLoadConfig rejetée:" << why;
|
return refuser(faultParams, why);
|
||||||
return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorInvalidParameter)}});
|
|
||||||
}
|
|
||||||
// store.setConfigs() a déjà persisté + émis changed() → l'arbitre reconstruit, la notif part.
|
// store.setConfigs() a déjà persisté + émis changed() → l'arbitre reconstruit, la notif part.
|
||||||
return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorNoError)}});
|
return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorNoError)}});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -89,6 +89,9 @@ public:
|
|||||||
* \c currentPower sans \c totalEnergyConsumed (mesuré sur l'ECS du banc).
|
* \c currentPower sans \c totalEnergyConsumed (mesuré sur l'ECS du banc).
|
||||||
*/
|
*/
|
||||||
bool progressMesurable(const LoadConfig &c, QString &cause) const;
|
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);
|
Q_INVOKABLE JsonReply *SetLoadConfig(const QVariantMap ¶ms);
|
||||||
|
|
||||||
// [Phase 2] Ratios canoniques (autoconsommation / autonomie), % [0,100].
|
// [Phase 2] Ratios canoniques (autoconsommation / autonomie), % [0,100].
|
||||||
|
|||||||
@ -63,7 +63,7 @@ void TestLoadModel::testOptimizerMayEmitOnlyWhitelistedCodes()
|
|||||||
DecisionCode::EvSurplus, DecisionCode::EvEcoMin, DecisionCode::EvIdle,
|
DecisionCode::EvSurplus, DecisionCode::EvEcoMin, DecisionCode::EvIdle,
|
||||||
DecisionCode::MissingForecastSite, DecisionCode::ForecastPayloadRefused,
|
DecisionCode::MissingForecastSite, DecisionCode::ForecastPayloadRefused,
|
||||||
DecisionCode::ForecastDateUnreadable, DecisionCode::TariffRefused,
|
DecisionCode::ForecastDateUnreadable, DecisionCode::TariffRefused,
|
||||||
DecisionCode::SpotMarketNotInBuild}) {
|
DecisionCode::LoadConfigRefused, DecisionCode::SpotMarketNotInBuild}) {
|
||||||
QVERIFY2(!DecisionCode::optimizerMayEmit(QString::fromLatin1(c)), c);
|
QVERIFY2(!DecisionCode::optimizerMayEmit(QString::fromLatin1(c)), c);
|
||||||
}
|
}
|
||||||
QVERIFY(!DecisionCode::optimizerMayEmit(QStringLiteral("CODE_QUI_NEXISTE_PAS")));
|
QVERIFY(!DecisionCode::optimizerMayEmit(QStringLiteral("CODE_QUI_NEXISTE_PAS")));
|
||||||
|
|||||||
@ -17,6 +17,7 @@
|
|||||||
#include "etm/types/drawcap.h"
|
#include "etm/types/drawcap.h"
|
||||||
#include "etm/types/ecotarget.h"
|
#include "etm/types/ecotarget.h"
|
||||||
#include "etm/config/loadconfigstore.h"
|
#include "etm/config/loadconfigstore.h"
|
||||||
|
#include "etm/types/decisionreason.h"
|
||||||
#include "etm/ratios/energyratioscalculator.h"
|
#include "etm/ratios/energyratioscalculator.h"
|
||||||
#include "etm/tariff/tariffconfig.h"
|
#include "etm/tariff/tariffconfig.h"
|
||||||
#include "etm/forecast/baseloadlog.h"
|
#include "etm/forecast/baseloadlog.h"
|
||||||
@ -28,6 +29,8 @@
|
|||||||
#include <QJsonObject>
|
#include <QJsonObject>
|
||||||
#include <QTimeZone>
|
#include <QTimeZone>
|
||||||
#include <QJsonArray>
|
#include <QJsonArray>
|
||||||
|
#include <QRegularExpression>
|
||||||
|
#include <QScopeGuard>
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
@ -531,6 +534,357 @@ void TestLoadModel::testLabelIsBoundedOnWriteNeverOnLoad()
|
|||||||
QFile::remove(cfgPath);
|
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<QVariantList>("charges");
|
||||||
|
QTest::addColumn<QVariantMap>("motif");
|
||||||
|
QTest::addColumn<bool>("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}}));
|
||||||
|
}
|
||||||
|
|
||||||
void TestLoadModel::testRankOriginFollowsTheRankNotTheWrite()
|
void TestLoadModel::testRankOriginFollowsTheRankNotTheWrite()
|
||||||
{
|
{
|
||||||
// [LM-1209-c] La marque dit « ce rang est un défaut, pas un choix ». Ce qui la lève est
|
// [LM-1209-c] La marque dit « ce rang est un défaut, pas un choix ». Ce qui la lève est
|
||||||
|
|||||||
@ -77,6 +77,23 @@ private slots:
|
|||||||
void testRankOriginFollowsTheRankNotTheWrite();
|
void testRankOriginFollowsTheRankNotTheWrite();
|
||||||
// Protocole optimiseur 1.2, Q7 — le libellé est borné à l'écriture, jamais au chargement
|
// Protocole optimiseur 1.2, Q7 — le libellé est borné à l'écriture, jamais au chargement
|
||||||
void testLabelIsBoundedOnWriteNeverOnLoad();
|
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();
|
||||||
|
|
||||||
// ── LM-1006-1 — le plafond de soutirage, étape 1 ───────────────────────
|
// ── LM-1006-1 — le plafond de soutirage, étape 1 ───────────────────────
|
||||||
//! On compare des MARGES, jamais des plafonds : le plus contraignant mord, et on sait
|
//! On compare des MARGES, jamais des plafonds : le plus contraignant mord, et on sait
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user