TA CORRECTION EST RETENUE, et elle porte sur le point qui compte. `measuredSince`
n'a pas la même sémantique des deux côtés : sur une charge il répond « depuis
quand on MESURE » et borne une fenêtre dont `targetWh` donne l'autre bout ; sur
les ratios il répond « depuis quand on COMPTE », et la paire ne borne aucune
fenêtre. Transposer un nom n'est pas transposer une sémantique.
Conséquence gravée : `periodStartedAt` n'est PAS la borne basse des ratios —
c'est `measuredSince`. Présenter comme journaliers des chiffres qui ne comptent
que depuis le dernier redémarrage serait exactement le silence que la parade
prétendait fermer. Mesuré à l'instant sur .75 : les taux couvrent 1,97 h quand
l'écran dirait 9 h.
Et la séparation d'écrans est écrite là où elle se lit : `periodStartedAt` sert le
bilan journalier, `measuredSince` sert les ratios. Ils arrivent dans la même
trame, leurs noms se ressemblent, et c'est par là que l'erreur passe.
VÉRIFIÉ À LA SONDE PLUTÔT QUE DÉDUIT DU NOM, comme demandé — et il y avait de
quoi vérifier, puisque la justification même de publier était « le recalcul
serait faux les jours de changement d'heure ». Encore fallait-il que le nôtre soit
juste ces jours-là.
· en vrai sur .75 : `periodStartedAt` == minuit local, recalculé indépendamment ;
· en test : les époques viennent d'un ORACLE calculé hors de Qt, pas re-dérivé
avec les primitives du code testé — une identité qui se referme sur elle-même
ne vérifierait rien ;
· 2026-03-29 fait 23 h, 2026-10-25 fait 25 h, les 363 autres 24 h. Un client
posant « la journée fait 86 400 s » serait juste presque toujours, et faux
deux fois par an, dont une en plein hiver de chauffage.
TROIS FOIS mes propres contrôles se sont trompés en cherchant ces deux dates : un
balayage qui n'a rien trouvé, puis deux calculs rendant 24 h partout. Cause :
l'arithmétique entre deux datetimes du MÊME fuseau est naïve — elle rend la durée
d'horloge murale, jamais la durée réelle. Le contrôle qui le prouve passe donc par
les époques, jamais par une soustraction de dates. C'est la même leçon que celle
qu'on vient de graver : un instrument peut répondre « normal » parce qu'il ne
mesure pas ce qu'on croit.
Suites : loadmodel 24/24 (+1), simulation 67/67, charging 48/48, spotmarket 32/32.
Rien à déployer : le moteur est inchangé depuis +etm56.
813 lines
40 KiB
C++
813 lines
40 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/types/drawcap.h"
|
||
#include "etm/types/ecotarget.h"
|
||
#include "etm/config/loadconfigstore.h"
|
||
#include "etm/ratios/energyratioscalculator.h"
|
||
|
||
#include <QTest>
|
||
#include <QDir>
|
||
#include <QFile>
|
||
#include <QJsonDocument>
|
||
#include <QJsonObject>
|
||
#include <QTimeZone>
|
||
#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é");
|
||
}
|
||
|
||
/*!
|
||
* \brief [§10 étape D] L'étalement uniforme, et le durcissement qu'il faut savoir DIRE.
|
||
*
|
||
* \par Règles couvertes
|
||
* \rule{LM-1013-b-i} (recalculé à chaque cycle), \rule{LM-1013-b-ii} (le durcissement se dit),
|
||
* \rule{LM-1013-b-iii} (point de non-retour nommé), \rule{LM-1013-b-iv} (l'heure quotidienne
|
||
* vise la prochaine occurrence).
|
||
*
|
||
* \par Ce que la mesure a montré avant que rien ne soit asserté
|
||
* L'étape D est la seule des quatre qui change le comportement, et aucun scénario existant ne
|
||
* portait d'obligation éco — `LoadContext::needs` n'était rempli par aucun adaptateur. Il n'y
|
||
* avait donc **aucune référence** à laquelle se comparer : les valeurs ci-dessous ont été
|
||
* relevées d'abord, épinglées ensuite. C'est ce relevé qui a révélé la bascule de période, que
|
||
* la lecture du code ne montrait pas.
|
||
*
|
||
* \par Le durcissement est mécanique, et c'est pourquoi le motif doit le porter
|
||
* 2 kWh restants font **333 W à six heures** de l'échéance, **2 kW à une heure**, et **7 kW —
|
||
* le plafond — dès seize minutes**. La fenêtre où l'achat devient massif n'arrive pas
|
||
* progressivement : elle arrive d'un coup. Un client qui voit sa facture grimper à six heures du
|
||
* matin sans cause lisible cherchera une panne.
|
||
*/
|
||
void TestLoadModel::testEcoSpreadHardensAndSaysSo()
|
||
{
|
||
LoadNeeds needs;
|
||
needs.minEnergyWhPerDay = 2000;
|
||
needs.dailyDeadline = "07:00";
|
||
|
||
EcoProgress p;
|
||
p.delivered = true;
|
||
p.deliveredWh = 0;
|
||
p.targetWh = 2000;
|
||
|
||
const QDate j(2026, 8, 31);
|
||
auto cible = [&](int h, int m) {
|
||
return computeEcoTarget(needs, p, 7000, QDateTime(j, QTime(h, m), QTimeZone::utc()));
|
||
};
|
||
|
||
// 1. LE DURCISSEMENT, relevé point par point. C'est la courbe que le motif doit rendre
|
||
// lisible — pas une valeur isolée, une progression.
|
||
QCOMPARE(qRound(cible(1, 0).watts), 333); // 6 h avant
|
||
QCOMPARE(qRound(cible(5, 0).watts), 1000); // 2 h avant
|
||
QCOMPARE(qRound(cible(6, 0).watts), 2000); // 1 h avant
|
||
QCOMPARE(qRound(cible(6, 30).watts), 4000); // 30 min
|
||
QCOMPARE(qRound(cible(6, 44).watts), 7000); // 16 min — déjà au plafond physique
|
||
|
||
// 2. Le POINT DE NON-RETOUR est franchi sous le seuil, et il se DIT. Sans lui, la cadence
|
||
// tend vers l'infini sans que rien ne décide : un seuil se discute, une asymptote se subit.
|
||
QVERIFY2(!cible(6, 44).pointDeNonRetour, "à 16 min le seuil n'est pas encore franchi");
|
||
QVERIFY2(cible(6, 50).pointDeNonRetour, "à 10 min il doit l'être");
|
||
QCOMPARE(qRound(cible(6, 50).watts), 7000);
|
||
|
||
// 3. La cadence est RECALCULÉE, jamais figée : un rattrapage après une heure sans surplus
|
||
// doit monter. Figée au départ, elle manquerait l'échéance en ayant l'air de travailler.
|
||
p.deliveredWh = 0;
|
||
const double aSixHeures = cible(1, 0).watts;
|
||
const double aDeuxHeures = cible(5, 0).watts;
|
||
QVERIFY2(aDeuxHeures > aSixHeures * 2.5,
|
||
"sans rien livré, la cadence doit avoir monté d'elle-même");
|
||
|
||
// 4. Ce qui est DÉJÀ LIVRÉ allège la cadence — l'étalement porte sur le RESTE.
|
||
p.deliveredWh = 1500;
|
||
QCOMPARE(qRound(cible(5, 0).watts), 250); // 500 Wh en 2 h
|
||
QCOMPARE(qRound(cible(5, 0).remainingWh), 500);
|
||
|
||
// 5. Obligation TENUE : plus rien à acheter pour l'éco.
|
||
p.deliveredWh = 2000;
|
||
QVERIFY2(cible(5, 0).tenue, "2000 sur 2000 : l'obligation est tenue");
|
||
QCOMPARE(qRound(cible(5, 0).watts), 0);
|
||
|
||
// 6. R6 — SOUS `unmeasurable`, ON NE CONCLUT JAMAIS « TENUE ». On ne sait pas ce qui a été
|
||
// livré ; l'affirmer serait affirmer sur ce qu'on ignore. On vise l'obligation entière,
|
||
// qui est le choix qui SERT plutôt que celui qui suppose acquis.
|
||
EcoProgress inconnu;
|
||
inconnu.delivered = false;
|
||
inconnu.targetWh = 2000;
|
||
const EcoTarget ti = computeEcoTarget(needs, inconnu, 7000,
|
||
QDateTime(j, QTime(5, 0), QTimeZone::utc()));
|
||
QVERIFY2(!ti.tenue, "jamais « tenue » sur un avancement qu'on ne mesure pas (LM-1009 C1)");
|
||
QCOMPARE(qRound(ti.remainingWh), 2000);
|
||
|
||
// 7. L'heure quotidienne lue le SOIR vise le LENDEMAIN. La lire comme dépassée ferait
|
||
// franchir le point de non-retour toutes les nuits, et acheter à pleine puissance chaque
|
||
// soir sans qu'aucune échéance ne l'exige.
|
||
const EcoTarget soir = computeEcoTarget(needs, p, 7000,
|
||
QDateTime(j, QTime(23, 0), QTimeZone::utc()));
|
||
QVERIFY2(!soir.pointDeNonRetour, "23:00 vers 07:00 : huit heures, pas un retard");
|
||
QCOMPARE(soir.remainingS, qint64(8 * 3600));
|
||
}
|
||
|
||
void TestLoadModel::testEcoUnmeasurableBuysAtAConstantRate()
|
||
{
|
||
// \rule{LM-1013-b-v} — sous `unmeasurable`, `remainingWh` est FIGÉ à la cible par contrat.
|
||
// Un rapport dont seul le dénominateur décroît ne suit plus un retard : il suit l'horloge.
|
||
LoadNeeds needs;
|
||
needs.minEnergyWhPerDay = 4000;
|
||
needs.dailyDeadline = "07:00";
|
||
|
||
EcoProgress inconnu;
|
||
inconnu.delivered = false; // le régime `unmeasurable`
|
||
inconnu.targetWh = 4000;
|
||
|
||
EcoProgress mesure; // même obligation, même instant, régime MESURÉ
|
||
mesure.delivered = true;
|
||
mesure.targetWh = 4000;
|
||
mesure.deliveredWh = 0; // rien livré : le numérateur vaut AUSSI 4000
|
||
|
||
const QDate j(2026, 9, 4);
|
||
const double PLAFOND = 7000;
|
||
auto inconnue = [&](int h, int m) {
|
||
return computeEcoTarget(needs, inconnu, PLAFOND, QDateTime(j, QTime(h, m), QTimeZone::utc()));
|
||
};
|
||
auto mesuree = [&](int h, int m) {
|
||
return computeEcoTarget(needs, mesure, PLAFOND, QDateTime(j, QTime(h, m), QTimeZone::utc()));
|
||
};
|
||
|
||
// 1. CONSTANTE sur toute la fenêtre. C'est l'assertion qui mord : sous l'ancienne loi ces
|
||
// cinq valeurs montaient de 167 W au plafond.
|
||
const int attendu = qRound(4000.0 / 24.0); // 167 W
|
||
QCOMPARE(qRound(inconnue(1, 0).watts), attendu); // 6 h avant
|
||
QCOMPARE(qRound(inconnue(5, 0).watts), attendu); // 2 h avant
|
||
QCOMPARE(qRound(inconnue(6, 0).watts), attendu); // 1 h avant
|
||
QCOMPARE(qRound(inconnue(6, 30).watts), attendu); // 30 min
|
||
QCOMPARE(qRound(inconnue(6, 55).watts), attendu); // 5 min — DANS le seuil de non-retour
|
||
|
||
// 2. `atMaximum` JAMAIS vrai, y compris sous le seuil. Le drapeau annonce que l'étalement a
|
||
// cessé ; sans étalement il n'a rien à annoncer, et le publier dirait qu'on est en retard
|
||
// sur un avancement qu'on ne mesure pas.
|
||
QVERIFY2(!inconnue(6, 55).pointDeNonRetour, "5 min avant : pas de point de non-retour");
|
||
QVERIFY2(!inconnue(6, 59).pointDeNonRetour, "1 min avant non plus");
|
||
|
||
// 3. LE TOTAL ACHETÉ VAUT EXACTEMENT LA CIBLE. C'est la propriété qui justifie la loi, et
|
||
// elle ne se lit sur aucune valeur isolée : il faut intégrer la fenêtre entière.
|
||
double total = 0;
|
||
QDateTime t(j, QTime(7, 1), QTimeZone::utc());
|
||
for (int minute = 0; minute < 24 * 60; ++minute) {
|
||
total += computeEcoTarget(needs, inconnu, PLAFOND, t).watts / 60.0; // Wh sur 1 min
|
||
t = t.addSecs(60);
|
||
}
|
||
QVERIFY2(qAbs(total - 4000.0) < 5.0,
|
||
qUtf8Printable(QString("total acheté sur la fenêtre : %1 Wh, attendu 4000")
|
||
.arg(total, 0, 'f', 1)));
|
||
|
||
// 4. CONTRE-ÉPREUVE — la loi n'a changé QUE pour ce régime. Même obligation, même instant,
|
||
// même numérateur (4000 non livrés) : sous `measured`, le durcissement est intact. Sans
|
||
// cette épreuve, avoir supprimé l'étalement POUR TOUT LE MONDE passerait inaperçu.
|
||
QCOMPARE(qRound(mesuree(1, 0).watts), 667); // 4 kWh en 6 h
|
||
QCOMPARE(qRound(mesuree(6, 0).watts), 4000); // 4 kWh en 1 h
|
||
QVERIFY2(mesuree(6, 55).pointDeNonRetour, "sous `measured`, le point de non-retour tient");
|
||
QCOMPARE(qRound(mesuree(6, 55).watts), qRound(PLAFOND));
|
||
|
||
// 5. L'ÉCART, au même instant, dit tout le lot : à cinq minutes de l'échéance, le régime
|
||
// mesuré prend 7000 W parce qu'il SAIT qu'il reste 4 kWh à livrer ; le régime inconnu en
|
||
// prend 167 parce qu'il ne sait rien — et ne pas savoir n'est pas une raison d'acheter.
|
||
QVERIFY2(mesuree(6, 55).watts > inconnue(6, 55).watts * 40,
|
||
"au même instant, les deux régimes doivent diverger d'un ordre de grandeur");
|
||
|
||
// 6. R6 tient toujours : on ne conclut jamais « tenue » sur un avancement qu'on ne mesure
|
||
// pas, et la cible reste INTACTE — ce que l'écran affiche comme reste dû.
|
||
QVERIFY2(!inconnue(5, 0).tenue, "jamais « tenue » sous `unmeasurable` (LM-1009 C1)");
|
||
QCOMPARE(qRound(inconnue(5, 0).remainingWh), 4000);
|
||
}
|
||
|
||
void TestLoadModel::testReportingDayBoundaryIsPublishedNotDeducible()
|
||
{
|
||
// \rule{LM-1005-b-iv} — la frontière de la période de reporting se PUBLIE. Ce test dit
|
||
// POURQUOI : sur deux jours de l'année, la journée locale ne fait pas 24 heures, et tout
|
||
// client qui la recalcule en supposant 24 h se trompe d'une heure sur un chiffre de facture.
|
||
const QTimeZone paris("Europe/Paris");
|
||
QVERIFY2(paris.isValid(), "sans base de fuseaux, ce test ne prouverait rien — il passerait");
|
||
|
||
auto frontiere = [&](const QDate &jour) {
|
||
// Un calculateur NEUF par appel : on interroge la frontière, pas l'état d'une baseline.
|
||
EnergyRatiosCalculator calc;
|
||
return calc.compute(0, 0, 0, 0, QDateTime(jour, QTime(12, 0), paris)).debutPeriode;
|
||
};
|
||
|
||
// 1. La frontière est bien MINUIT LOCAL — comparé à un oracle calculé indépendamment
|
||
// (Python/zoneinfo), et non re-dérivé avec les mêmes primitives Qt que le code testé.
|
||
// Une identité qui se referme sur elle-même ne vérifierait rien.
|
||
QCOMPARE(frontiere(QDate(2026, 9, 5)).toSecsSinceEpoch(), qint64(1788559200));
|
||
QCOMPARE(frontiere(QDate(2026, 3, 29)).toSecsSinceEpoch(), qint64(1774738800));
|
||
QCOMPARE(frontiere(QDate(2026, 3, 30)).toSecsSinceEpoch(), qint64(1774821600));
|
||
QCOMPARE(frontiere(QDate(2026,10, 25)).toSecsSinceEpoch(), qint64(1792879200));
|
||
QCOMPARE(frontiere(QDate(2026,10, 26)).toSecsSinceEpoch(), qint64(1792969200));
|
||
|
||
auto dureeH = [&](const QDate &jour) {
|
||
return (frontiere(jour.addDays(1)).toSecsSinceEpoch()
|
||
- frontiere(jour).toSecsSinceEpoch()) / 3600;
|
||
};
|
||
|
||
// 2. LA RAISON DE PUBLIER, en trois nombres. Un client qui poserait « la journée fait
|
||
// 86 400 s » serait juste 363 jours sur 365, et faux les deux autres — dont l'un tombe en
|
||
// plein hiver de chauffage. C'est l'erreur silencieuse par excellence : elle ne se voit
|
||
// pas à l'écran, elle se voit sur la facture, un mois plus tard.
|
||
QCOMPARE(dureeH(QDate(2026, 9, 5)), qint64(24));
|
||
QCOMPARE(dureeH(QDate(2026, 3, 29)), qint64(23));
|
||
QCOMPARE(dureeH(QDate(2026,10, 25)), qint64(25));
|
||
|
||
// 3. Et le recalcul naïf DIVERGE, explicitement : « frontière + 24 h » ne retombe pas sur la
|
||
// frontière suivante ces jours-là. L'écrire ainsi évite qu'on lise le point 2 comme une
|
||
// curiosité de calendrier plutôt que comme un défaut de client.
|
||
QVERIFY2(frontiere(QDate(2026, 3, 29)).addSecs(24 * 3600) != frontiere(QDate(2026, 3, 30)),
|
||
"au printemps, +24 h dépasse la frontière suivante d'une heure");
|
||
QVERIFY2(frontiere(QDate(2026,10, 25)).addSecs(24 * 3600) != frontiere(QDate(2026,10, 26)),
|
||
"à l'automne, +24 h reste une heure en deçà");
|
||
|
||
// 4. Le fuseau publié est celui QUI A SERVI, pas celui du système. Sans cette assertion, un
|
||
// changement de fuseau de la machine de test ferait passer ou échouer le test pour une
|
||
// raison qui n'a rien à voir avec le code.
|
||
QCOMPARE(frontiere(QDate(2026, 3, 29)).timeZone().id(), QByteArray("Europe/Paris"));
|
||
}
|
||
|
||
void TestLoadModel::testDrawMarginComparesHeadroomsNotCaps()
|
||
{
|
||
// [LM-1006-1] Un plafond PAR PHASE et un plafond TOTAL n'ont pas la même forme et ne se
|
||
// convertissent pas : une maison peut tirer 20 A sur une phase et 2 A sur les deux autres,
|
||
// et « un tiers du total » supposerait un équilibre que rien ne garantit. Ce qui se compare,
|
||
// ce sont les MARGES — des watts dans les deux cas.
|
||
|
||
// ---- Aucun plafond : la marge est SANS OBJET, pas nulle -------------------------------
|
||
// La distinction n'est pas cosmétique : une marge nulle bornerait tout à 0 W et couperait
|
||
// l'installation. C'est le « zéro forgé » du contrat, appliqué à une couche de sécurité.
|
||
const DrawMargin rien = resolveDrawMargin({}, {0, 0, 0}, 0);
|
||
QVERIFY2(!rien.borne, "aucun plafond déclaré doit rendre une marge SANS OBJET");
|
||
|
||
// ---- Une seule source, par phase : c'est la phase la PLUS CHARGÉE qui décide -----------
|
||
const DrawCap disjoncteur{DrawCapSource::Breaker, 5000, -1};
|
||
DrawMargin m = resolveDrawMargin({disjoncteur}, {1000, 4500, 200}, 5700);
|
||
QVERIFY(m.borne);
|
||
QCOMPARE(qRound(m.watts), 500); // 5000 − 4500, la phase B
|
||
QCOMPARE(m.source, DrawCapSource::Breaker);
|
||
QCOMPARE(m.phase, 1); // et on DIT laquelle
|
||
|
||
// ---- Deux formes en concurrence : la plus petite marge mord ---------------------------
|
||
// 3000 W restants au total contre 500 W sur la phase B : c'est la phase qui borne, alors
|
||
// même que son plafond (5000) est plus BAS que le total (8700). Comparer les plafonds
|
||
// aurait donné la réponse inverse.
|
||
const DrawCap gestionnaire{DrawCapSource::GridOperator, -1, 8700};
|
||
m = resolveDrawMargin({gestionnaire, disjoncteur}, {1000, 4500, 200}, 5700);
|
||
QCOMPARE(qRound(m.watts), 500);
|
||
QCOMPARE(m.source, DrawCapSource::Breaker);
|
||
|
||
// …et l'inverse, sur la même mesure : un ordre du gestionnaire plus serré prend la main, et
|
||
// le motif publié doit alors dire « gestionnaire de réseau », jamais « surcharge ».
|
||
const DrawCap ordre{DrawCapSource::GridOperator, -1, 6000};
|
||
m = resolveDrawMargin({disjoncteur, ordre}, {1000, 4500, 200}, 5700);
|
||
QCOMPARE(qRound(m.watts), 300); // 6000 − 5700
|
||
QCOMPARE(m.source, DrawCapSource::GridOperator);
|
||
QCOMPARE(m.phase, -1); // un plafond total n'a pas de phase
|
||
|
||
// ---- Marge NÉGATIVE : le plafond est déjà dépassé, et de combien ----------------------
|
||
// C'est ce que la couche L4 doit délester. Écrêter à 0 perdrait l'ampleur du dépassement.
|
||
m = resolveDrawMargin({disjoncteur}, {1000, 6200, 200}, 7400);
|
||
QCOMPARE(qRound(m.watts), -1200);
|
||
QCOMPARE(m.phase, 1);
|
||
|
||
// ---- Marges ÉGALES : le départage est STABLE, pas dépendant de l'ordre du tableau -----
|
||
// Sans règle, le motif publié changerait au gré de l'ordre d'insertion — donc sans qu'aucune
|
||
// décision n'ait changé, ce qui est exactement ce qu'un motif ne doit jamais faire.
|
||
const DrawCap eco{DrawCapSource::EcoSelfImposed, -1, 6000};
|
||
const DrawCap ordre2{DrawCapSource::GridOperator, -1, 6000};
|
||
const DrawMargin a = resolveDrawMargin({eco, ordre2}, {2000, 2000, 2000}, 5700);
|
||
const DrawMargin b = resolveDrawMargin({ordre2, eco}, {2000, 2000, 2000}, 5700);
|
||
QCOMPARE(a.source, b.source);
|
||
QCOMPARE(a.source, DrawCapSource::GridOperator); // imposé > auto-imposé
|
||
|
||
// ---- Les codes franchissent la frontière RPC, et ils sont fermés ----------------------
|
||
QCOMPARE(drawCapSourceCode(DrawCapSource::Breaker), QStringLiteral("connection"));
|
||
QCOMPARE(drawCapSourceCode(DrawCapSource::GridOperator), QStringLiteral("gridOperator"));
|
||
QCOMPARE(drawCapSourceCode(DrawCapSource::EcoSelfImposed), QStringLiteral("selfImposed"));
|
||
}
|