Patrick Schurig 8927b0033c feat(§12): LM-1209 — insertion en queue, rang unique, et la création se dit
Les trois conditions, d'un bloc.

INSERTION EN QUEUE. provisionEvChargerConfig() posait rang min − 1, écrasé à 1 —
donc à égalité avec la charge que l'installateur avait posée là exprès. C'est
désormais 1 + max(rangs). Queue de LISTE et non de domaine : les domaines ne sont
pas contigus, donc « juste après le dernier ev » exigerait de pousser une charge
d'un autre domaine, ce que la condition 2 interdit. Et comme max(domaine) ≤
max(liste), la queue de liste est toujours au moins aussi reculée — elle coïncide
quand le domaine occupe la fin, et sinon place la nouvelle charge derrière tout
le monde plutôt que de l'intercaler devant une charge classée. Plus fidèle au
principe que la formulation d'origine.

UNICITÉ. validateRanks() refuse une écriture où deux charges portent le même
rang. Avec la mise en garde écrite noir sur blanc : l'égalité ne coûte pas le
déterminisme — le tri est total sur (rang, identifiant) depuis 3g-2 — elle coûte
la gouvernabilité. S'y tromper ferait chercher le défaut dans le tri.

ET LA RÈGLE GÉNÉRALE QUI EN SORT (LM-1209-a-bis) : une vérification qui ÉCARTE et
une vérification qui REFUSE ne peuvent pas être la même fonction. validateSet()
nomme ce qui rend l'ensemble dangereux et load() s'en sert pour écarter ; y
ajouter l'unicité de rang aurait fait perdre des charges au démarrage sur une
configuration héritée — disparues de la configuration ET de l'arbitrage, sans
applySafeState, soit ECS-413 rouvert par une autre porte. Les deux sont
séparées ; validateRanks() n'est appelée que par setConfigs(). Au chargement, un
doublon hérité est signalé bruyamment et CONSERVÉ.

o:rankOrigin. Énumération fermée "auto" | "user", absente par défaut. Une
énumération et non un booléen parce que le troisième état est le point :
`rankIsDefault: false` affirmerait que le rang a été choisi, alors que c'est ce
qu'on ignore d'une configuration héritée. L'absence doit porter le « on ne sait
pas » ; un booléen ne sait pas se taire. Même motif que measurement.source, et la
spec le nomme désormais comme un motif de conception plutôt qu'une décision au
cas par cas.

Et la précision demandée en écrivant : l'effacement porte sur le RANG, jamais sur
la réception d'une écriture. SetLoadConfig remplace en bloc, donc réordonner une
AUTRE charge renvoie forcément l'entrée de celle-ci — l'effacer à cette occasion
perdrait une marque encore vraie. L'omission ne lève rien ; pour lever sans
déplacer, un client envoie "user". Un "auto" fabriqué est refusé, l'aller-retour
verbatim reste licite.

Trois tests. testAutoProvisionedRankGoesToTheQueue vérifié échouant avec
l'insertion en tête — et il échoue en disant que la borne n'entre PAS en
configuration, les deux mécanismes se répondant : le rang libre par construction
et le refus d'écriture en filet. testRankOriginFollowsTheRankNotTheWrite couvre
le cas qui se serait perdu, un client naïf qui omet la clé en déplaçant une autre
charge. testRankUniquenessRefusesWriteButNeverDiscards couvre la moitié qui ne se
voit pas : qu'un chargement conserve ce qu'il aurait pu écarter.

amd64 0 erreur / 0 avertissement. simulation 31/31, charging 16/16, loadmodel
20/20, spotmarket 7/7.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-08-28 15:56:46 +02:00

537 lines
25 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// SPDX-License-Identifier: GPL-3.0-or-later
#include "testloadmodel.h"
#include "etm/adapters/relayrouter.h"
#include "etm/energyarbitrator.h"
#include "etm/types/loadconfig.h"
#include "etm/config/loadconfigstore.h"
#include <QTest>
#include <QDir>
#include <QFile>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonArray>
namespace {
//! Une config relay-router minimale, valide, dont on ne fait varier que les relais.
LoadConfig relayLoad(const QList<QPair<QString, int>> &relays,
const QString &label = QStringLiteral("chauffe-eau"),
int priority = 1,
const QString &meter = QString(),
const QString &sensor = QString())
{
QVariantList list;
for (const auto &r : relays) {
QVariantMap m;
m.insert("thingId", r.first);
m.insert("powerW", r.second);
list.append(m);
}
QVariantMap map;
map.insert("id", "chauffe-eau");
map.insert("label", label);
map.insert("adapter", "relay-router");
map.insert("mode", "fixed");
map.insert("priority", priority);
map.insert("enabled", true);
map.insert("minOnS", 60);
map.insert("minOffS", 60);
map.insert("relays", list);
if (!meter.isEmpty())
map.insert("meterThingId", meter);
if (!sensor.isEmpty())
map.insert("sensorThingId", sensor);
return LoadConfig::fromMap(map);
}
const QString K1 = QStringLiteral("{aaaaaaaa-0000-0000-0000-000000000001}");
const QString K2 = QStringLiteral("{bbbbbbbb-0000-0000-0000-000000000002}");
const QString K3 = QStringLiteral("{cccccccc-0000-0000-0000-000000000003}");
} // namespace
void TestLoadModel::testStageTableIsDerivedFromSubsetSums()
{
// La configuration réelle du banc .75 : trois contacteurs 500 / 1 000 / 2 000 W.
const RelayStageTable t = RelayRouter::deriveStages(
relayLoad({{K1, 500}, {K2, 1000}, {K3, 2000}}).relaysList());
QCOMPARE(t.levels, (QList<int>{0, 500, 1000, 1500, 2000, 2500, 3000, 3500}));
QCOMPARE(t.mapping.size(), t.levels.size());
// Le palier 0 est le sous-ensemble vide : aucun contact fermé. C'est ce qui garantit
// qu'un routeur sait toujours s'arrêter.
QCOMPARE(t.levels.first(), 0);
QVERIFY(t.mapping.first().isEmpty());
// Et le maximum est bien la somme de tout — c'est le maxStageW publié en télémétrie.
QCOMPARE(t.levels.last(), 3500);
QCOMPARE(t.mapping.last().size(), 3);
}
void TestLoadModel::testRelayOrderIsInertWhenPowersAreDistinct()
{
const RelayStageTable direct = RelayRouter::deriveStages(
relayLoad({{K1, 500}, {K2, 1000}, {K3, 2000}}).relaysList());
const RelayStageTable inverse = RelayRouter::deriveStages(
relayLoad({{K3, 2000}, {K2, 1000}, {K1, 500}}).relaysList());
// Chaque somme n'a qu'une seule combinaison possible : la déduplication n'arbitre rien,
// donc l'ordre de déclaration ne peut rien changer d'ÉLECTRIQUE.
QCOMPARE(direct.levels, inverse.levels);
// Les listes de ThingIds, elles, diffèrent : elles sont collectées dans l'ordre de
// déclaration, donc le palier 1 500 W sort {K1,K2} d'un côté et {K2,K1} de l'autre.
// C'est le même geste électrique — d'où la comparaison par ENSEMBLE dans la table.
// Ce cas a fait échouer la première version de operator==, qui comparait les listes.
QVERIFY2(direct == inverse,
"un réordonnancement à puissances distinctes doit rendre la même table");
}
void TestLoadModel::testRelayOrderDecidesWhichContactServesATiedStage()
{
// Deux relais de MÊME puissance : le palier 1 000 W est atteignable par K1 seul comme
// par K2 seul. La table retient la PREMIÈRE combinaison rencontrée.
const RelayStageTable a = RelayRouter::deriveStages(
relayLoad({{K1, 1000}, {K2, 1000}}).relaysList());
const RelayStageTable b = RelayRouter::deriveStages(
relayLoad({{K2, 1000}, {K1, 1000}}).relaysList());
// Mêmes puissances atteignables…
QCOMPARE(a.levels, (QList<int>{0, 1000, 2000}));
QCOMPARE(a.levels, b.levels);
// …mais pas le même contacteur pour le palier 1 000 W. C'est un vrai changement
// matériel : autre contact qui s'use, autre résistance qui chauffe.
QCOMPARE(a.mapping.at(1), QList<QString>{K1});
QCOMPARE(b.mapping.at(1), QList<QString>{K2});
QVERIFY(a != b);
}
void TestLoadModel::testReorderingDistinctRelaysKeepsTheAdapter()
{
const LoadConfig avant = relayLoad({{K1, 500}, {K2, 1000}, {K3, 2000}});
const LoadConfig apres = relayLoad({{K3, 2000}, {K1, 500}, {K2, 1000}});
// Le geste que l'installateur fait dans l'app : remonter une ligne. Mêmes Things,
// mêmes puissances, même table — donc aucune reconstruction, donc pas de contacts
// ouverts par l'état sûr ni de verrous réarmés à froid.
QVERIFY2(EnergyArbitrator::sameHardware(avant, apres),
"un réordonnancement sans effet sur la table coupait la charge");
}
void TestLoadModel::testSwappingTiedRelaysRebuilds()
{
const LoadConfig avant = relayLoad({{K1, 1000}, {K2, 1000}});
const LoadConfig apres = relayLoad({{K2, 1000}, {K1, 1000}});
// Ici la table change : le palier 1 000 W passe d'un contacteur à l'autre. Comparer les
// relais comme un ENSEMBLE aurait manqué ce changement et laissé l'adaptateur commander
// le mauvais contact.
QVERIFY2(!EnergyArbitrator::sameHardware(avant, apres),
"un échange de contacts à puissance égale doit reconstruire");
}
void TestLoadModel::testSoftFieldsNeverRebuild()
{
const QList<QPair<QString, int>> relays{{K1, 500}, {K2, 1000}};
const LoadConfig avant = relayLoad(relays, QStringLiteral("chauffe-eau"), 1);
const LoadConfig apres = relayLoad(relays, QStringLiteral("Ballon 200 L"), 3);
// Renommer et changer de rang se met à jour EN PLACE (ECS-412). Le libellé part
// désormais à l'adaptateur par updateSoftConfig() — sans quoi le journal de la box
// continuait d'imprimer l'ancien nom alors que GetLoadConfig rendait le nouveau.
QVERIFY(EnergyArbitrator::sameHardware(avant, apres));
}
// ─────────────────────────────────────────────────────────────────────────────
// §11 — compteur et sonde par charge
// ─────────────────────────────────────────────────────────────────────────────
void TestLoadModel::testMeasurementThingsRoundTrip()
{
const QString meter = QStringLiteral("{dddddddd-0000-0000-0000-00000000000m}");
const QString sensor = QStringLiteral("{eeeeeeee-0000-0000-0000-00000000000s}");
const LoadConfig avec = relayLoad({{K1, 500}}, QStringLiteral("ECS"), 1, meter, sensor);
QCOMPARE(avec.meterThingId(), meter);
QCOMPARE(avec.sensorThingId(), sensor);
const QVariantMap m = avec.toMap();
QCOMPARE(m.value("meterThingId").toString(), meter);
QCOMPARE(m.value("sensorThingId").toString(), sensor);
// Omis quand vides — jamais une chaîne vide persistée, même règle que « domain ».
const QVariantMap sans = relayLoad({{K1, 500}}).toMap();
QVERIFY2(!sans.contains("meterThingId"), "un compteur absent ne doit pas sortir à \"\"");
QVERIFY2(!sans.contains("sensorThingId"), "une sonde absente ne doit pas sortir à \"\"");
}
void TestLoadModel::testMeasurementThingsAreNotClaimed()
{
const QString meter = QStringLiteral("{dddddddd-0000-0000-0000-00000000000m}");
const QString sensor = QStringLiteral("{eeeeeeee-0000-0000-0000-00000000000s}");
const LoadConfig c = relayLoad({{K1, 500}, {K2, 1000}}, QStringLiteral("ECS"), 1,
meter, sensor);
// LM-1104 : ces Things MESURENT, ils ne commandent pas. LM-201 ne s'applique donc pas.
const QSet<QString> claimed = c.claimedThingIds();
QCOMPARE(claimed.size(), 2); // les deux relais, et eux seuls
QVERIFY2(!claimed.contains(meter.toLower()), "le compteur ne doit pas être revendiqué");
QVERIFY2(!claimed.contains(sensor.toLower()), "la sonde ne doit pas être revendiquée");
}
void TestLoadModel::testTwoLoadsMayShareAMeter()
{
const QString meter = QStringLiteral("{dddddddd-0000-0000-0000-00000000000m}");
const QString sensor = QStringLiteral("{eeeeeeee-0000-0000-0000-00000000000s}");
LoadConfig a = relayLoad({{K1, 500}}, QStringLiteral("ECS haut"), 1, meter, sensor);
LoadConfig b = relayLoad({{K2, 1000}}, QStringLiteral("ECS bas"), 2, meter, sensor);
b.setId(QStringLiteral("ecs-bas"));
// Un compteur de tableau divisionnaire couvrant deux charges, une sonde de pièce
// partagée : deux configurations parfaitement correctes, que la symétrie avec relays[]
// aurait fait refuser.
LoadConfigs ensemble;
ensemble << a << b;
QString err;
QVERIFY2(LoadConfigStore::validateSet(ensemble, &err), qPrintable(err));
}
void TestLoadModel::testMeasurementThingsAreSoftFields()
{
const QList<QPair<QString, int>> relays{{K1, 500}, {K2, 1000}};
const LoadConfig avant = relayLoad(relays, QStringLiteral("ECS"), 1);
const LoadConfig apres = relayLoad(relays, QStringLiteral("ECS"), 1,
QStringLiteral("{dddddddd-0000-0000-0000-00000000000m}"),
QStringLiteral("{eeeeeeee-0000-0000-0000-00000000000s}"));
// Rattacher un compteur ne touche à AUCUN matériel commandé : pas de reconstruction,
// donc pas de contacts ouverts ni de verrous réarmés à froid pour un geste de mesure.
QVERIFY2(EnergyArbitrator::sameHardware(avant, apres),
"rattacher un compteur ne doit pas reconstruire la charge");
}
// ─────────────────────────────────────────────────────────────────────────────
// §12 — le plancher de modulation
// ─────────────────────────────────────────────────────────────────────────────
namespace {
//! Une charge continue minimale : un seul Thing, un plafond, éventuellement un plancher.
LoadConfig continuousLoad(int maxPowerW, int minPowerW)
{
QVariantMap map;
map.insert("id", "{11111111-2222-3333-4444-555555555555}");
map.insert("label", "Routeur PV salon");
map.insert("adapter", "etmvariableload");
map.insert("mode", "dynamic");
map.insert("priority", 1);
map.insert("enabled", true);
map.insert("maxPowerW", maxPowerW);
if (minPowerW > 0)
map.insert("minPowerW", minPowerW);
return LoadConfig::fromMap(map);
}
} // namespace
void TestLoadModel::testFloorIsDeclaredForContinuousLoads()
{
const LoadConfig c = continuousLoad(3000, 300);
QString err;
QVERIFY2(c.isValid(&err), qPrintable(err));
QCOMPARE(c.minPowerW(), 300u);
QCOMPARE(c.toMap().value("minPowerW").toUInt(), 300u);
// Omis quand nul — un plancher de 0 W se lirait « accepte n'importe quelle miette ».
QVERIFY(!continuousLoad(3000, 0).toMap().contains("minPowerW"));
}
void TestLoadModel::testFloorIsRefusedWhereItWouldBeDerived()
{
// relay-router : le plancher est le plus petit palier dérivé des relais.
QVariantMap relais = relayLoad({{K1, 500}, {K2, 1000}}).toMap();
relais.insert("minPowerW", 500);
QString err;
QVERIFY2(!LoadConfig::fromMap(relais).isValid(&err),
"un plancher déclaré sur relay-router doit être refusé");
QVERIFY(err.contains("minPowerW"));
// etmvariableload *fixed* : idem, le plancher est le plus petit palier non nul.
QVariantMap fixe;
fixe.insert("id", "{11111111-2222-3333-4444-555555555555}");
fixe.insert("label", "ECS multipalier");
fixe.insert("adapter", "etmvariableload");
fixe.insert("mode", "fixed");
fixe.insert("priority", 1);
fixe.insert("enabled", true);
fixe.insert("powerLevels", QVariantList() << 0 << 600 << 1200);
fixe.insert("minPowerW", 600);
QVERIFY2(!LoadConfig::fromMap(fixe).isValid(&err),
"un plancher déclaré en mode fixed doit être refusé");
}
void TestLoadModel::testFloorAboveCeilingIsRefused()
{
// {0} ∪ [min, max] serait réduit à {0} : la charge ne démarrerait jamais, et rien
// dans le journal ne dirait pourquoi.
QString err;
QVERIFY2(!continuousLoad(2000, 3000).isValid(&err),
"un plancher au-dessus du plafond doit être refusé");
QVERIFY(err.contains("minPowerW"));
// Égal au plafond : la charge est tout ou rien, mais elle EST atteignable.
QVERIFY2(continuousLoad(3000, 3000).isValid(&err), qPrintable(err));
}
QTEST_MAIN(TestLoadModel)
namespace {
//! Une entrée de borne telle que l'auto-provisionnement la crée : un rang, rien d'autre.
QVariantMap borneMap(const QString &id, int priority = 1)
{
QVariantMap m;
m.insert("id", id);
m.insert("label", "Borne du garage");
m.insert("adapter", "evcharger");
m.insert("mode", "dynamic");
m.insert("domain", "ev");
m.insert("priority", priority);
m.insert("enabled", true);
return m;
}
const QString BORNE = QStringLiteral("{dddddddd-0000-0000-0000-00000000000e}");
} // namespace
void TestLoadModel::testEvChargerConfigIsRankOnly()
{
QString err;
const LoadConfig c = LoadConfig::fromMap(borneMap(BORNE, 2));
QVERIFY2(c.isValid(&err), qUtf8Printable(err));
QVERIFY(c.isEvCharger());
QCOMPARE(c.priority(), 2);
// LM-302 — aller-retour NEUTRE. C'est le geste ordinaire de l'app : lire, changer un rang,
// renvoyer l'objet entier. Une entrée qui ne survivrait pas à sa propre relecture rendrait
// le réordonnancement impossible depuis l'écran.
const LoadConfig relu = LoadConfig::fromMap(c.toMap());
QVERIFY2(relu.isValid(&err), qUtf8Printable(err));
QCOMPARE(relu.toMap(), c.toMap());
QCOMPARE(relu.priority(), 2);
QCOMPARE(relu.adapter(), QStringLiteral("evcharger"));
QCOMPARE(relu.domain(), QStringLiteral("ev"));
// Le mode est celui d'une charge qui module. « fixed » n'aurait pas de sens : une borne
// n'a pas de paliers, et l'accepter ouvrirait une forme que rien ne construit.
QVariantMap fixe = borneMap(BORNE);
fixe.insert("mode", "fixed");
QVERIFY2(!LoadConfig::fromMap(fixe).isValid(&err), "evcharger en mode fixed doit être refusé");
QVERIFY(err.contains("dynamic"));
}
void TestLoadModel::testEvChargerConfigRefusesPayloads()
{
QString err;
// Les limites d'une borne viennent du THING et changent avec la voiture branchée. Les
// déclarer ici en ferait une seconde source de vérité, périmée dès le premier débranchement.
QVariantMap avecPlafond = borneMap(BORNE);
avecPlafond.insert("maxPowerW", 11000);
QVERIFY2(!LoadConfig::fromMap(avecPlafond).isValid(&err), "maxPowerW doit être refusé");
QVERIFY(err.contains("maxPowerW"));
QVariantMap avecPlancher = borneMap(BORNE);
avecPlancher.insert("minPowerW", 4140);
QVERIFY2(!LoadConfig::fromMap(avecPlancher).isValid(&err), "minPowerW doit être refusé");
QVERIFY(err.contains("minPowerW"));
QVariantMap avecRelais = borneMap(BORNE);
QVariantMap relais;
relais.insert("thingId", K1);
relais.insert("powerW", 2000);
avecRelais.insert("relays", QVariantList() << relais);
QVERIFY2(!LoadConfig::fromMap(avecRelais).isValid(&err), "relays[] doit être refusé");
QVariantMap avecVerrous = borneMap(BORNE);
avecVerrous.insert("minOnS", 300);
QVERIFY2(!LoadConfig::fromMap(avecVerrous).isValid(&err), "minOnS doit être refusé");
QVERIFY(err.contains("minOnS"));
}
void TestLoadModel::testEvChargerClaimsItsThing()
{
// LM-201 — l'id de la charge EST le ThingId de la borne, donc elle le revendique. Sans
// cela, on pourrait déclarer la même borne une seconde fois comme charge pilotée : deux
// commandeurs sur un même organe, et sa puissance comptée deux fois au budget.
const LoadConfig borne = LoadConfig::fromMap(borneMap(BORNE));
QVERIFY(borne.claimedThingIds().contains(QUuid(BORNE).toString()));
QVariantMap doublon;
doublon.insert("id", BORNE); // le MÊME Thing, par un autre mécanisme
doublon.insert("label", "Borne pilotée en watts");
doublon.insert("adapter", "etmvariableload");
doublon.insert("mode", "dynamic");
doublon.insert("maxPowerW", 11000);
doublon.insert("priority", 3);
doublon.insert("enabled", true);
LoadConfigs ensemble;
ensemble << borne << LoadConfig::fromMap(doublon);
QString err;
QVERIFY2(!LoadConfigStore::validateSet(ensemble, &err),
"deux charges actives sur le Thing d'une borne doivent être refusées");
}
void TestLoadModel::testRankUniquenessRefusesWriteButNeverDiscards()
{
// [LM-1209-a] Deux vérifications, deux gestes — et les confondre convertirait une règle de
// contrat en PERTE DE CONFIGURATION au démarrage.
//
// validateSet() nomme ce qui rend l'ensemble DANGEREUX (deux charges sur un organe) et
// load() s'en sert pour ÉCARTER l'entrée fautive ;
// validateRanks() nomme ce qui le rend MAL FORMÉ, et le seul geste correct est de
// REFUSER l'écriture entière.
QString err;
LoadConfigs doublon;
doublon.append(LoadConfig::fromMap(borneMap(BORNE, 1)));
QVariantMap autre = borneMap(QStringLiteral("{dddddddd-0000-0000-0000-00000000000f}"), 1);
autre.insert("label", "Seconde borne");
doublon.append(LoadConfig::fromMap(autre));
// Chaque entrée est valide isolément, et l'ensemble n'est PAS dangereux : aucune des deux
// ne commande le Thing de l'autre. C'est pour cela que validateSet() le laisse passer —
// et pour cela qu'il fallait une seconde vérification, pas un durcissement de celle-ci.
for (const LoadConfig &c : doublon)
QVERIFY2(c.isValid(&err), qUtf8Printable(err));
QVERIFY2(LoadConfigStore::validateSet(doublon, &err),
"un doublon de RANG n'est pas un conflit d'organe : validateSet() ne doit pas le voir");
// La bonne forme, elle, refuse — et le motif doit NOMMER ce qui est perdu. Ce n'est pas le
// déterminisme : depuis 3g-2 le tri est total sur (rang, identifiant). C'est que l'ordre
// obéirait à des identifiants, donc à personne.
QVERIFY2(!LoadConfigStore::validateRanks(doublon, &err),
"deux charges au même rang doivent être refusées à l'écriture");
QVERIFY2(err.contains(QStringLiteral("RÉGLABLE")), qUtf8Printable(err));
// …et une écriture entière est rejetée, pas amputée : rien n'est persisté à moitié.
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-ranks.json";
QFile::remove(cfgPath);
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
{
LoadConfigStore store;
QVERIFY2(!store.setConfigs(doublon, &err), "setConfigs doit refuser un doublon de rang");
QCOMPARE(store.configs().count(), 0);
}
// ---- Et au CHARGEMENT : conservé, jamais écarté --------------------------------------
// C'est la moitié qui compte. Une configuration héritée à rangs doublés existe (le banc en
// a porté une) ; l'écarter ferait disparaître des charges de la configuration ET de
// l'arbitrage, sans applySafeState — sur la seule foi d'une règle resserrée après coup.
QFile f(cfgPath);
QVERIFY(f.open(QIODevice::WriteOnly | QIODevice::Text));
QVariantList brut;
for (const LoadConfig &c : doublon)
brut.append(c.toMap());
f.write(QJsonDocument(QJsonObject{{"loads", QJsonArray::fromVariantList(brut)}}).toJson());
f.close();
LoadConfigStore relu;
QCOMPARE(relu.configs().count(), 2); // LES DEUX survivent
QCOMPARE(relu.configs().at(0).priority(), 1);
QCOMPARE(relu.configs().at(1).priority(), 1); // aucun rang n'a été « réparé » d'office
// Et la première écriture cliente répare, sans que rien n'ait été perdu entre-temps.
LoadConfigs repare = relu.configs();
repare[1].setPriority(2);
QVERIFY2(relu.setConfigs(repare, &err), qUtf8Printable(err));
QCOMPARE(relu.configs().count(), 2);
}
void TestLoadModel::testRankOriginFollowsTheRankNotTheWrite()
{
// [LM-1209-c] La marque dit « ce rang est un défaut, pas un choix ». Ce qui la lève est
// qu'on CLASSE la charge — pas qu'on écrive dans le magasin.
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-rankorigin.json";
QFile::remove(cfgPath);
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
QString err;
LoadConfigStore store;
// Deux charges : « posee » classée par un humain au rang 1, « auto » créée d'office en 2.
LoadConfigs init;
QVariantMap posee = borneMap(QStringLiteral("{dddddddd-0000-0000-0000-0000000000a1}"), 1);
posee.insert("label", "Borne posée");
init.append(LoadConfig::fromMap(posee));
LoadConfig creee = LoadConfig::fromMap(borneMap(BORNE, 2));
creee.setRankOrigin(QStringLiteral("auto")); // le MOTEUR l'écrit, lui en a le droit
init.append(creee);
QVERIFY2(store.setConfigs(init, &err), qUtf8Printable(err));
// ---- Réordonner une AUTRE charge ne lève PAS la marque ---------------------------------
// C'est le cas qui se serait perdu. SetLoadConfig remplace en bloc : déplacer « posee »
// renvoie forcément l'entrée de la borne créée d'office. Si l'effacement portait sur
// « écriture reçue » plutôt que sur « rang changé », la marque tomberait ici — alors que
// la charge n'a toujours été classée par personne.
LoadConfigs bougeLautre = store.configs();
bougeLautre[0].setPriority(3); // on déplace « posee », pas la borne
bougeLautre[1].setRankOrigin(QString()); // et le client OMET la marque, comme un
// client naïf qui reconstruit sa charge utile
LoadConfigs reconciliee;
QVERIFY2(store.reconcileRankOrigin(bougeLautre, &reconciliee, &err), qUtf8Printable(err));
QVERIFY2(store.setConfigs(reconciliee, &err), qUtf8Printable(err));
QCOMPARE(store.configs().at(1).rankOrigin(), QStringLiteral("auto"));
QCOMPARE(store.configs().at(1).priority(), 2);
// ---- Déplacer LA charge lève la marque -------------------------------------------------
LoadConfigs bougeLaBonne = store.configs();
bougeLaBonne[1].setPriority(9);
QVERIFY2(store.reconcileRankOrigin(bougeLaBonne, &reconciliee, &err), qUtf8Printable(err));
QVERIFY2(store.setConfigs(reconciliee, &err), qUtf8Printable(err));
// Vers le VIDE, pas vers "user" : le moteur constate un déplacement, il ne sait pas si un
// humain ou un script l'a voulu. Ne rien affirmer est le minimum honnête.
QVERIFY2(store.configs().at(1).rankOrigin().isEmpty(),
"un rang changé doit lever la marque, sans affirmer autre chose à la place");
// ---- Un client ne FABRIQUE pas un "auto" -----------------------------------------------
LoadConfigs fabrique = store.configs();
fabrique[1].setRankOrigin(QStringLiteral("auto"));
QVERIFY2(!store.reconcileRankOrigin(fabrique, &reconciliee, &err),
"un client ne doit pas pouvoir affirmer que le moteur a créé ce rang");
QVERIFY2(err.contains(QStringLiteral("moteur")), qUtf8Printable(err));
// ---- …mais l'aller-retour VERBATIM reste licite ----------------------------------------
// Sans cette tolérance, tout client qui renvoie ce qu'il a lu serait refusé — la
// neutralité Get→Set du contrat serait rompue.
LoadConfigs init2;
LoadConfig c2 = LoadConfig::fromMap(borneMap(BORNE, 2));
c2.setRankOrigin(QStringLiteral("auto"));
init2.append(c2);
LoadConfigStore store2;
QVERIFY2(store2.setConfigs(init2, &err), qUtf8Printable(err));
LoadConfigs verbatim = store2.configs(); // relu tel quel, marque comprise
QVERIFY2(store2.reconcileRankOrigin(verbatim, &reconciliee, &err), qUtf8Printable(err));
QCOMPARE(reconciliee.at(0).rankOrigin(), QStringLiteral("auto"));
// ---- « user » est la voie EXPLICITE pour lever sans déplacer ---------------------------
LoadConfigs assume = store2.configs();
assume[0].setRankOrigin(QStringLiteral("user"));
QVERIFY2(store2.reconcileRankOrigin(assume, &reconciliee, &err), qUtf8Printable(err));
QCOMPARE(reconciliee.at(0).rankOrigin(), QStringLiteral("user"));
// Et l'énumération est fermée — une valeur libre serait une affirmation qu'on devrait
// supporter le jour où quelqu'un la lit.
LoadConfig hors = LoadConfig::fromMap(borneMap(BORNE, 1));
hors.setRankOrigin(QStringLiteral("installateur"));
QVERIFY2(!hors.isValid(&err), "rankOrigin hors énumération doit être refusé");
}