Le relevé machine du 2026-08-26 l'énonçait sans détour : « tant que ceci n'est
pas traité, la transplantation ne commande rien ». L'arbitre décidait,
publiait un motif juste, et adjustEvChargers() commandait autre chose une
milliseconde plus tard. Ce lot ferme les trois points que le banc a ouverts.
1. UNE CHARGE, UN COMMANDEUR
Les actions EV du waterfall sont enfin DISPATCHÉES — elles ne l'étaient pas du
tout : m_loadAdapters ne contient pas les bornes, et applyActionsToAdapters()
s'arrêtait là. Et les bornes ainsi commandées sortent du chemin proxy par deux
crochets virtuels posés dans l'amont, interrogés à deux moments du cycle où la
réponse n'est pas la même : evSurplusPlannedByWaterfall() avant le plan (à qui
la borne appartient-elle), evCommandedByWaterfall() après (qu'a-t-il commandé).
Sans cette distinction, une borne servie par l'échéance ne serait plus
commandée du tout.
Le jeu des bornes commandées est NOMMÉ par le scheduler (Plan::waterfallEvIds).
Le redéduire ailleurs — « motif différent d'EV_DEADLINE », « financement au
surplus » — dériverait du vrai au premier motif ajouté, et EV_GRID_START porte
justement funding=Grid tout en venant du waterfall. Il est vidé à l'entrée du
mode dégradé : le waterfall ne commandant plus, il ne peut plus revendiquer.
chargingState, que posait adjustEvChargers(), est repris par l'arbitre pour
les bornes qui n'y passent plus — sinon l'app afficherait « en charge » une
borne que l'arbitre vient d'éteindre.
2. evFloorW() REND L'EXIGENCE RÉELLE, ET LA TOLÉRANCE DEVIENT VISIBLE
Le plancher valait `min × acquisitionTolerance × 230 × phases` : 2 070 W
annoncés pour une borne qui en exige 4 140. Le waterfall allouait 2 828 W,
l'adaptateur refusait d'enclencher sous 6 A, et 2 828 W étaient retirés du
budget pour personne. Ce sont deux grandeurs différentes : le plancher est ce
que la borne EXIGE, la tolérance est la part qu'on accepte de ne pas couvrir.
La tolérance n'est pas supprimée pour autant — un réglage documenté (défaut
0,5) qui devient inerte sans le dire est un silence ambigu, et tout le parc
verrait son VE démarrer deux fois plus tard sans explication. Elle devient un
seuil de démarrage EXPLICITE : motif EV_GRID_START {budgetW, floorW, gridW},
funding=Grid, et une comptabilité qui se partage — budgetW dans
budget.allocatedW, gridW dans budget.evReservedW, budgetW + gridW ==
allocatedW.
C'est une brèche assumée dans le financement réseau que DESIGN_3g §3.1 déférait
à 3f. Elle est bornée par construction, pas par discipline : surplus réel exigé
(budgetW > 0), tout le budget restant consommé donc AU PLUS UNE borne par
cycle, et le plafond de la protection de surcharge s'applique avant. Écrite en
toutes lettres dans DESIGN_3g §3.1-bis.
3. LE RANG D'UNE BORNE EST CONFIGURABLE
Il vit là où vivent tous les rangs : LoadConfig.priority (LM-1206/LM-1207). Pas
de second système de priorité — un classement propre aux bornes ne saurait pas
exprimer « VE1 > ECS > VE2 », et deux classements qui ne peuvent pas
s'interclasser sont un défaut, pas deux fonctionnalités. ChargingInfo ne porte
donc aucun champ de rang.
Toute borne détectée reçoit d'office une entrée « evcharger », portant le rang
et RIEN d'autre : ses limites viennent du Thing et changent avec la voiture
branchée. Création journalisée, une ligne par borne, à la création seulement —
une écriture que personne n'a demandée doit laisser une trace. Le tri devient
TOTAL : à rang égal, l'identifiant départage, donc laquelle démarre est
reproductible même avant tout réglage.
L'entrée SURVIT à la disparition de son Thing : borne remplacée, ThingId
changé, appareil déposé. Elle reste publiée available: false / faultCode:
THING_MISSING / motif LOAD_UNAVAILABLE, et ne retient aucun budget. La
supprimer d'office rendrait « borne remplacée » indistinguable de « borne
jamais configurée ».
CORRIGE UNE RUPTURE D'INVARIANT DE 3g-1, non signalée à l'époque : en faisant
entrer les bornes dans loads[], 3g-1 publiait des charges qui ne figuraient
dans AUCUNE GetLoadConfig — la charge fantôme que le lot B-bis avait
supprimée, réapparue par l'autre bout. L'app s'appuie sur cet invariant pour
résoudre libellé, domaine et rang. L'auto-provisionnement le rétablit par
construction.
ÉPROUVÉ
- testEvChargerRankDecidesWhoCharges : deux bornes, un budget qui ne paie
qu'un plancher, la seconde refusée avec BELOW_MIN_POWER {budgetW: 620,
minPowerW: 1380}, et l'inversion du rang change la borne servie. C'est la
moitié ARBITRAGE de LM-1204-b. Couvre aussi l'aller-retour neutre par la
frontière RPC et l'inclusion loads[] ⊆ GetLoadConfig.
- testEvGridStartIsAnnouncedAndBounded : le motif chiffre le soutirage, la
comptabilité se réconcilie, une seule borne soutire par cycle.
- testEvChargerConfigOutlivesItsThing : l'entrée survit, publiée en défaut.
- testEvChargerConfigIsRankOnly / RefusesPayloads / ClaimsItsThing.
Deux points de contrôle de `run` bougent, dans un seul scénario et par une
cause unique : la conversion watts→ampères se fait désormais après
l'allocation, et l'arrondi tombe parfois de l'autre côté. 14 des 16 scénarios
sont identiques au bit près, et ce qui compte est inchangé dans les deux —
la voiture est à 100 % à l'échéance.
Suite complète : simulation 27/27, charging 16/16, loadmodel 16/16,
spotmarket 7/7. Build amd64 0/0.
RIEN N'A TOURNÉ SUR MACHINE. docs/RECAP_3g2.md dit ce qui reste à éprouver au
banc — dont LM-1204-b avec deux bornes réelles, la Terra AC étant vérifiée
absente du réseau (ARP INCOMPLETE depuis le banc lui-même).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TVywtayEeoRJ2yWUW4iZj
388 lines
17 KiB
C++
388 lines
17 KiB
C++
// 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>
|
||
|
||
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");
|
||
}
|