Deux gardes sur lastConfirmedAt, demandées et tranchées. JAMAIS UNE CLÉ DE RÉVISION. firstIssuedAt identifie la prévision ; la fraîcheur dit seulement jusqu'à quand elle a tenu. Dater avec elle ferait paraître une prévision née au moment où elle a été reconduite — et deux prévisions différentes reconduites par le même run porteraient la même date. Écrit dans l'en-tête, au contrat ET dans un test : une interdiction qui ne vit qu'à un endroit n'est pas une interdiction. SUR UNE LIGNE JAMAIS RECONDUITE, ELLE VAUT firstIssuedAt — jamais absente. Et la tranche vient de ce que le champ RÉPOND : il ne dit pas « quand quelqu'un a confirmé » mais « quel est le dernier run qui a AFFIRMÉ ces valeurs ». Le run émetteur les a affirmées. La réponse est vraie, et uniformément — il n'y a pas un cas confirmation et un cas copie, il y a une seule question avec une seule sorte de réponse. L'ambiguïté redoutée ne se lève pas en choisissant une convention : elle se lève en DÉFINISSANT correctement le champ. Une fois la définition posée, les deux cas n'en font plus qu'un. Et ici l'absence serait le mensonge, à rebours de la règle habituelle du dépôt : elle se lirait « on ne sait pas jusqu'à quand elle a tenu » alors qu'on le sait, et forcerait chaque client à écrire `confirmedAt ?? issuedAt` — ce calcul-ci, ailleurs, une fois par client, avec une chance de le faire de travers. Invariant publié : lastConfirmedAt >= firstIssuedAt, toujours. Une prévision ne peut pas avoir tenu avant d'avoir été émise. Sur le nom : lastConfirmedAt et non confirmedAt — le préfixe dit qu'il s'agit du DERNIER d'une suite, là où confirmedAt se lirait comme l'instant d'un événement unique, et une ligne reconduite quatre fois n'a pas « un » instant de confirmation. loadmodel 31/31, charging 48/48, spotmarket 32/32, ci-quality 4/4, build 0 erreur 0 warning. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
1181 lines
61 KiB
C++
1181 lines
61 KiB
C++
// SPDX-License-Identifier: GPL-3.0-or-later
|
||
|
||
#include "etm/forecast/forecaststore.h"
|
||
#include <QTemporaryDir>
|
||
#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::testMinimumIsServedEvenWhenTheObligationIsMet()
|
||
{
|
||
// \rule{LM-1013-b-vi} — « Surplus + minimum réseau ». Une PUISSANCE demandée tant que la
|
||
// charge est là, distincte de l'obligation en ÉNERGIE : on ne sait pas combien de temps une
|
||
// voiture reste branchée, donc rien ne fournit le dénominateur d'une cadence.
|
||
const QDate j(2026, 9, 7);
|
||
const QDateTime cinqHeuresAvant(j, QTime(2, 0), QTimeZone::utc()); // échéance à 07:00
|
||
|
||
EcoProgress mesure;
|
||
mesure.delivered = true;
|
||
mesure.targetWh = 2000;
|
||
|
||
LoadNeeds nu; // aucun besoin déclaré du tout
|
||
LoadNeeds obligationSeule;
|
||
obligationSeule.minEnergyWhPerDay = 2000;
|
||
obligationSeule.dailyDeadline = "07:00";
|
||
LoadNeeds minimumSeul;
|
||
minimumSeul.minPowerWAlways = 4140; // 6 A x 3 phases x 230 V
|
||
LoadNeeds lesDeux = obligationSeule;
|
||
lesDeux.minPowerWAlways = 4140;
|
||
|
||
// 1. AUCUN MINIMUM DEMANDÉ — comportement STRICTEMENT inchangé. C'est la garantie que
|
||
// l'ajout ne déplace rien pour les charges qui n'en veulent pas, et c'est la première
|
||
// chose à vérifier : un correctif qui change ce qu'il ne devait pas toucher est un
|
||
// défaut, pas une amélioration.
|
||
mesure.deliveredWh = 0;
|
||
const EcoTarget sansMin = computeEcoTarget(obligationSeule, mesure, 7000, cinqHeuresAvant);
|
||
QCOMPARE(qRound(sansMin.watts), 400); // 2000 Wh en 5 h
|
||
QCOMPARE(qRound(sansMin.minimumW), 0);
|
||
QVERIFY(!sansMin.borneParLeMinimum);
|
||
QVERIFY2(!computeEcoTarget(nu, mesure, 7000, cinqHeuresAvant).applicable,
|
||
"sans aucun besoin, la passe éco n'émet rien — R1");
|
||
|
||
// 2. MINIMUM SEUL, sans obligation ni échéance. C'est le cas de la borne : rien ne permet
|
||
// de calculer une cadence, et le minimum n'en a pas besoin.
|
||
const EcoTarget minSeul = computeEcoTarget(minimumSeul, EcoProgress(), 7000, cinqHeuresAvant);
|
||
QVERIFY2(minSeul.applicable, "un minimum suffit à rendre la charge éligible à la passe éco");
|
||
QCOMPARE(qRound(minSeul.watts), 4140);
|
||
QCOMPARE(qRound(minSeul.minimumW), 4140);
|
||
QVERIFY(minSeul.borneParLeMinimum);
|
||
QVERIFY2(!minSeul.tenue, "un minimum ne se « tient » jamais : il n'a pas de cible");
|
||
|
||
// 3. LE CŒUR DU LOT — obligation TENUE et minimum encore demandé. La cadence vaut 0 et la
|
||
// consigne doit retomber SUR LE MINIMUM, pas sur zéro. C'est ici, et seulement ici, que
|
||
// la différence de nature entre une énergie et une puissance se voit dans le code.
|
||
mesure.deliveredWh = 2000; // obligation entièrement livrée
|
||
const EcoTarget tenu = computeEcoTarget(lesDeux, mesure, 7000, cinqHeuresAvant);
|
||
QVERIFY2(tenu.tenue, "l'obligation en énergie est bien tenue");
|
||
QCOMPARE(qRound(tenu.watts), 4140);
|
||
QVERIFY2(tenu.borneParLeMinimum, "c'est le minimum qui fixe la consigne, et le motif doit le dire");
|
||
// Contre-épreuve : la MÊME situation sans minimum retombe à zéro. Sans elle, on ne saurait
|
||
// pas si 4 140 vient du minimum ou d'un reliquat d'obligation mal calculé.
|
||
QCOMPARE(qRound(computeEcoTarget(obligationSeule, mesure, 7000, cinqHeuresAvant).watts), 0);
|
||
|
||
// 4. L'OBLIGATION PLUS EXIGEANTE L'EMPORTE, et le motif dit alors « obligation ». À une
|
||
// heure de l'échéance, 2 000 Wh demandent 2 000 W — sous le minimum, donc c'est encore
|
||
// lui qui mord ; à dix minutes, le point de non-retour prend le plafond physique.
|
||
mesure.deliveredWh = 0;
|
||
const EcoTarget uneHeure = computeEcoTarget(lesDeux, mesure, 7000, QDateTime(j, QTime(6, 0), QTimeZone::utc()));
|
||
QCOMPARE(qRound(uneHeure.watts), 4140);
|
||
QVERIFY2(uneHeure.borneParLeMinimum, "2 000 W de cadence sont SOUS le minimum de 4 140");
|
||
const EcoTarget dixMin = computeEcoTarget(lesDeux, mesure, 7000, QDateTime(j, QTime(6, 50), QTimeZone::utc()));
|
||
QCOMPARE(qRound(dixMin.watts), 7000);
|
||
QVERIFY2(!dixMin.borneParLeMinimum, "au point de non-retour, c'est l'obligation qui commande");
|
||
|
||
// 5. ÉCRÊTÉ AU PLAFOND PHYSIQUE, comme l'obligation. Demander plus que ce que la charge sait
|
||
// prendre n'inventerait qu'un chiffre.
|
||
LoadNeeds minTropGrand;
|
||
minTropGrand.minPowerWAlways = 9000;
|
||
QCOMPARE(qRound(computeEcoTarget(minTropGrand, EcoProgress(), 7000, cinqHeuresAvant).watts), 7000);
|
||
}
|
||
|
||
void TestLoadModel::testStoredInstantsSurviveATimeZoneChange()
|
||
{
|
||
// Un instant ABSOLU ne bouge pas quand l'installation change de fuseau ; une FRONTIÈRE
|
||
// locale, si. Les deux vivent dans la même charge utile et se ressemblent — c'est par là
|
||
// que le défaut est passé (mesuré le 2026-09-06, bascule Paris→London sans redémarrage :
|
||
// measuredSince a bougé de +3600 s alors qu'il datait une pose déjà faite).
|
||
const QTimeZone paris("Europe/Paris"), londres("Europe/London");
|
||
QVERIFY2(paris.isValid() && londres.isValid(), "sans base de fuseaux ce test ne prouve rien");
|
||
|
||
// L'instant de pose, tel que le code le capture désormais : ancré à la capture.
|
||
const QDateTime pose = QDateTime(QDate(2026, 9, 6), QTime(15, 36, 42), paris).toUTC();
|
||
const qint64 epoquePose = pose.toSecsSinceEpoch();
|
||
|
||
// 1. L'ANCRAGE TIENT : re-rendre dans un autre fuseau ne déplace pas l'instant.
|
||
QCOMPARE(pose.toTimeZone(londres).toSecsSinceEpoch(), epoquePose);
|
||
QCOMPARE(pose.toUTC().toSecsSinceEpoch(), epoquePose);
|
||
|
||
// 2. CE QUI ARRIVAIT SANS L'ANCRAGE, et c'est la contre-épreuve : un QDateTime en
|
||
// spécification LocalTime ne garde que des composantes d'horloge murale, donc son
|
||
// époque se résout AVEC LE FUSEAU COURANT. Reproduit ici en construisant la même
|
||
// lecture murale dans les deux fuseaux — c'est exactement ce que fait Qt quand
|
||
// /etc/localtime bascule sous un objet déjà stocké.
|
||
const QDateTime muraleParis(QDate(2026, 9, 6), QTime(15, 36, 42), paris);
|
||
const QDateTime muraleLondres(QDate(2026, 9, 6), QTime(15, 36, 42), londres);
|
||
QCOMPARE(muraleLondres.toSecsSinceEpoch() - muraleParis.toSecsSinceEpoch(), qint64(3600));
|
||
QVERIFY2(muraleLondres.toSecsSinceEpoch() != epoquePose,
|
||
"sans ancrage, la même lecture murale rend une autre époque — c'est le défaut");
|
||
|
||
// 3. LA FRONTIÈRE, ELLE, DOIT BOUGER. Minuit local n'est pas un instant absolu : c'est une
|
||
// propriété du fuseau, et la publier fixe serait l'autre erreur, symétrique.
|
||
const QDateTime minuitParis(QDate(2026, 9, 6), QTime(0, 0), paris);
|
||
const QDateTime minuitLondres(QDate(2026, 9, 6), QTime(0, 0), londres);
|
||
QCOMPARE(minuitLondres.toSecsSinceEpoch() - minuitParis.toSecsSinceEpoch(), qint64(3600));
|
||
|
||
// 4. Et c'est là toute la distinction, en une assertion : le même décalage de +3600 s est
|
||
// un DÉFAUT sur l'instant de pose et le COMPORTEMENT ATTENDU sur la frontière. Le nombre
|
||
// ne dit pas lequel des deux on regarde — seule la nature du champ le dit.
|
||
QCOMPARE(muraleLondres.toSecsSinceEpoch() - muraleParis.toSecsSinceEpoch(),
|
||
minuitLondres.toSecsSinceEpoch() - minuitParis.toSecsSinceEpoch());
|
||
}
|
||
|
||
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"));
|
||
}
|
||
|
||
namespace {
|
||
//! Fabrique une révision : heures consécutives à partir de \a depuis, une valeur par heure.
|
||
ForecastRevision revision(const QDateTime &recue, const QDateTime &depuis,
|
||
const QList<double> &wh)
|
||
{
|
||
ForecastRevision r;
|
||
r.issuedAt = recue;
|
||
for (int i = 0; i < wh.count(); ++i) {
|
||
// Les deux instants de LECTURE ne se renseignent pas à l'écriture : le magasin les pose.
|
||
ForecastPoint p;
|
||
p.targetAt = depuis.addSecs(i * 3600);
|
||
p.wh = wh.at(i);
|
||
r.points.append(p);
|
||
}
|
||
return r;
|
||
}
|
||
}
|
||
|
||
void TestLoadModel::testARevisionExistsOnlyIfTheForecastChanged()
|
||
{
|
||
QTemporaryDir dir;
|
||
ForecastStore magasin(dir.filePath("forecast.sqlite"));
|
||
QVERIFY(magasin.isValid());
|
||
|
||
const QDateTime run06 = QDateTime(QDate(2026, 9, 9), QTime(6, 0), QTimeZone::utc());
|
||
const QDateTime run12 = QDateTime(QDate(2026, 9, 9), QTime(12, 0), QTimeZone::utc());
|
||
const QDateTime run18 = QDateTime(QDate(2026, 9, 9), QTime(18, 0), QTimeZone::utc());
|
||
const QDateTime t0 = QDateTime(QDate(2026, 9, 9), QTime(10, 0), QTimeZone::utc());
|
||
|
||
// 1. Premier run : une révision s'ouvre.
|
||
QVERIFY(magasin.enregistrer(revision(run06, t0, {100, 200, 300, 400})));
|
||
QCOMPARE(magasin.revisions(t0).count(), 1);
|
||
|
||
// 2. LE RUN DE 12Z RECONDUIT LES MÊMES CHIFFRES — ce n'est PAS une révision. On ne stocke
|
||
// pas une confirmation pour un consommateur qui n'existe pas : celui d'aujourd'hui est un
|
||
// affichage, et un affichage ne lit pas une confirmation. Même raisonnement que celui qui
|
||
// a écarté la requête meta.json.
|
||
QVERIFY2(!magasin.enregistrer(revision(run12, t0, {100, 200, 300, 400})),
|
||
"un run qui reconduit les mêmes valeurs n'ouvre pas de révision");
|
||
QCOMPARE(magasin.revisions(t0).count(), 1);
|
||
|
||
// 3. MAIS LA FRAÎCHEUR A ÉTÉ MISE À JOUR, et c'est là que les DEUX instants se séparent.
|
||
// Les confondre ferait lire « prévu à 06Z » sur une ligne que le run 12Z vient de
|
||
// reconduire — la prévision paraîtrait vieille de six heures alors qu'elle est fraîche.
|
||
// C'est le piège receivedAt/issuedAt de la veille, déplacé d'un cran.
|
||
QList<ForecastPoint> vue = magasin.courbe(t0, t0.addSecs(3 * 3600), run12.addSecs(60));
|
||
QCOMPARE(vue.count(), 4);
|
||
QCOMPARE(vue.first().firstIssuedAt, run06); // ANCIENNETÉ DE L'AVIS
|
||
QCOMPARE(vue.first().lastConfirmedAt, run12); // FRAÎCHEUR
|
||
QVERIFY2(vue.first().firstIssuedAt != vue.first().lastConfirmedAt,
|
||
"les deux instants doivent pouvoir diverger — sinon l'un des deux ment");
|
||
|
||
// 4. UNE VALEUR QUI CHANGE OUVRE UNE RÉVISION, et les deux instants se rejoignent.
|
||
// Contre-épreuve du cas 2 : sans elle, on ne saurait pas si la déduplication distingue
|
||
// quoi que ce soit ou refuse tout.
|
||
QVERIFY(magasin.enregistrer(revision(run18, t0, {100, 999, 300, 400})));
|
||
QCOMPARE(magasin.revisions(t0).count(), 2);
|
||
vue = magasin.courbe(t0, t0.addSecs(3 * 3600), run18.addSecs(60));
|
||
QCOMPARE(vue.first().firstIssuedAt, run18);
|
||
QCOMPARE(vue.first().lastConfirmedAt, run18); // à l'ouverture, ils coïncident
|
||
|
||
// 5. LA CLÉ NE BOUGE PAS SOUS LA LIGNE. Une reconduction met à jour la fraîcheur et rien
|
||
// d'autre : la révision de 06Z reste identifiée par 06Z, sans quoi l'identité de la ligne
|
||
// changerait et l'historique deviendrait illisible.
|
||
const QList<QPair<QDateTime, double>> revs = magasin.revisions(t0);
|
||
QCOMPARE(revs.first().first, run06);
|
||
QCOMPARE(revs.last().first, run18);
|
||
|
||
// 6. Une révision VIDE n'en est pas une : l'absence est un trou, pas une courbe de zéros.
|
||
ForecastRevision vide; vide.issuedAt = run18.addSecs(3600);
|
||
QVERIFY(!magasin.enregistrer(vide));
|
||
}
|
||
|
||
void TestLoadModel::testTheCurveIsReproducibleAndKeepsItsHoles()
|
||
{
|
||
QTemporaryDir dir;
|
||
ForecastStore magasin(dir.filePath("forecast.sqlite"));
|
||
QVERIFY(magasin.isValid());
|
||
|
||
const QDateTime t0 = QDateTime(QDate(2026, 9, 9), QTime(12, 0), QTimeZone::utc());
|
||
const QDateTime r08 = QDateTime(QDate(2026, 9, 9), QTime(8, 0), QTimeZone::utc());
|
||
const QDateTime r14 = QDateTime(QDate(2026, 9, 9), QTime(14, 0), QTimeZone::utc());
|
||
|
||
QVERIFY(magasin.enregistrer(revision(r08, t0, {1000, 1100, 1200}))); // « à 8 h »
|
||
QVERIFY(magasin.enregistrer(revision(r14, t0, {700, 800, 900}))); // révisé « à 14 h »
|
||
|
||
// « CE QU'ON PRÉVOYAIT À 8 H » est une question à part entière, et elle se pose en fixant
|
||
// l'instant de connaissance. Sans ce paramètre, la comparaison prévu/réalisé n'aurait pas
|
||
// d'horizon nommé, et deux écrans montreraient deux courbes pour le même jour.
|
||
const QList<ForecastPoint> vue08 = magasin.courbe(t0, t0.addSecs(7200), r08.addSecs(60));
|
||
QCOMPARE(vue08.count(), 3);
|
||
QCOMPARE(qRound(vue08.at(0).wh), 1000);
|
||
|
||
// La même fenêtre, connue plus tard : la révision de 14 h l'emporte.
|
||
const QList<ForecastPoint> vue14 = magasin.courbe(t0, t0.addSecs(7200), r14.addSecs(60));
|
||
QCOMPARE(vue14.count(), 3);
|
||
QCOMPARE(qRound(vue14.at(0).wh), 700);
|
||
|
||
// Les DEUX révisions coexistent pour la même heure visée — c'est tout l'objet de la table.
|
||
// Avec `targetAt` seul pour clé, la seconde aurait écrasé la première.
|
||
QCOMPARE(magasin.revisions(t0).count(), 2);
|
||
|
||
// LES TROUS RESTENT DES TROUS. On demande six heures, trois sont connues : la courbe en rend
|
||
// trois, jamais six dont trois à zéro — un zéro se lirait « on prévoit une nuit ».
|
||
const QList<ForecastPoint> large = magasin.courbe(t0, t0.addSecs(6 * 3600), r14.addSecs(60));
|
||
QCOMPARE(large.count(), 3);
|
||
|
||
// Et une fenêtre entièrement inconnue rend une liste VIDE, pas une courbe de zéros.
|
||
QVERIFY(magasin.courbe(t0.addDays(5), t0.addDays(5).addSecs(7200), r14.addSecs(60)).isEmpty());
|
||
}
|
||
|
||
void TestLoadModel::testRetentionKeepsBothEndsOfEachRevision()
|
||
{
|
||
QTemporaryDir dir;
|
||
ForecastStore magasin(dir.filePath("forecast.sqlite"));
|
||
QVERIFY(magasin.isValid());
|
||
|
||
// Une heure visée ancienne, quatre révisions successives.
|
||
const QDateTime cible = QDateTime(QDate(2026, 9, 1), QTime(12, 0), QTimeZone::utc());
|
||
for (int i = 0; i < 4; ++i) {
|
||
ForecastRevision r;
|
||
r.issuedAt = cible.addSecs(-(4 - i) * 3600);
|
||
ForecastPoint pt;
|
||
pt.targetAt = cible;
|
||
pt.wh = 1000.0 + i;
|
||
r.points.append(pt);
|
||
QVERIFY(magasin.enregistrer(r));
|
||
}
|
||
QCOMPARE(magasin.revisions(cible).count(), 4);
|
||
|
||
// Purge prise BIEN APRÈS : la fenêtre pleine de sept jours est dépassée.
|
||
const int retires = magasin.purger(cible.addDays(30));
|
||
QCOMPARE(retires, 2); // les deux intermédiaires
|
||
|
||
// LES DEUX BOUTS SURVIVENT, et c'est le cœur de la règle : la première répond à « la veille
|
||
// valait-elle quelque chose ? », la dernière à « que pouvait-on savoir de mieux ? ».
|
||
// N'en garder qu'une détruirait la révision, c'est-à-dire l'objet de la table.
|
||
const QList<QPair<QDateTime, double>> restant = magasin.revisions(cible);
|
||
QCOMPARE(restant.count(), 2);
|
||
QCOMPARE(qRound(restant.first().second), 1000); // horizon le plus long
|
||
QCOMPARE(qRound(restant.last().second), 1003); // meilleure estimation
|
||
|
||
// CONTRE-ÉPREUVE — une heure visée RÉCENTE garde toutes ses révisions. Sans elle, une purge
|
||
// qui viderait tout passerait pour une purge correcte.
|
||
const QDateTime recente = QDateTime::currentDateTimeUtc().addSecs(3600);
|
||
for (int i = 0; i < 3; ++i) {
|
||
ForecastRevision r;
|
||
r.issuedAt = QDateTime::currentDateTimeUtc().addSecs(i * 60);
|
||
ForecastPoint pt;
|
||
pt.targetAt = recente;
|
||
pt.wh = 500.0 + i;
|
||
r.points.append(pt);
|
||
QVERIFY(magasin.enregistrer(r));
|
||
}
|
||
magasin.purger();
|
||
QCOMPARE(magasin.revisions(recente).count(), 3);
|
||
}
|
||
|
||
void TestLoadModel::testAnUnknownPayloadIsRefusedNotBestEffort()
|
||
{
|
||
// La charge utile traverse une frontière de PROCESSUS : aucun compilateur ne vérifie sa
|
||
// forme. Une lecture « au mieux » d'une version inconnue produirait une courbe fausse qui
|
||
// aurait l'air de marcher, et le symptôme d'un changement de forme serait « courbe vide » —
|
||
// indistinguable d'une nuit.
|
||
const qint64 t0 = QDateTime(QDate(2026, 9, 9), QTime(0, 0), QTimeZone::utc()).toSecsSinceEpoch();
|
||
const qint64 run = QDateTime(QDate(2026, 9, 9), QTime(6, 0), QTimeZone::utc()).toSecsSinceEpoch();
|
||
auto charge = [&](int v, const QString &unite, const QVariantList &p) {
|
||
return QVariantMap{{"v", v}, {"t0", t0}, {"step", 3600}, {"unit", unite},
|
||
{"convention", "mean over [t, t+step)"}, {"regime", "forecast"},
|
||
{"corrections", "none"}, {"model", "meteofrance_seamless"},
|
||
{"issuedAt", run}, {"p", p}};
|
||
};
|
||
QString refus;
|
||
|
||
// 1. NOMINAL — et la conversion suit la convention LUE, pas supposée : p[i] est une moyenne
|
||
// en W sur le créneau, donc l'énergie vaut w x step / 3600.
|
||
const ForecastRevision ok = ForecastStore::depuisTransport(
|
||
charge(1, "W", QVariantList{0, 250, 1200}), refus);
|
||
QVERIFY2(refus.isEmpty(), qUtf8Printable(refus));
|
||
QCOMPARE(ok.points.count(), 3);
|
||
QCOMPARE(qRound(ok.points.at(1).wh), 250); // 250 W sur 3600 s
|
||
QCOMPARE(ok.points.at(1).targetAt.toSecsSinceEpoch(), t0 + 3600); // indexation rétrograde
|
||
QCOMPARE(ok.issuedAt.toSecsSinceEpoch(), run);
|
||
QCOMPARE(ok.model, QString("meteofrance_seamless"));
|
||
|
||
// 2. VERSION INCONNUE — refusée, et le motif la NOMME. Pas de courbe partielle.
|
||
QVERIFY(ForecastStore::depuisTransport(charge(2, "W", QVariantList{0, 250}), refus)
|
||
.points.isEmpty());
|
||
QCOMPARE(refus, QString("VERSION_2"));
|
||
|
||
// 3. UNITÉ INATTENDUE — refusée aussi. Lire des Wh comme des W donnerait une courbe
|
||
// plausible et fausse d'un facteur qui dépend du pas : exactement le genre de nombre que
|
||
// personne ne rattrape.
|
||
QVERIFY(ForecastStore::depuisTransport(charge(1, "Wh", QVariantList{0, 250}), refus)
|
||
.points.isEmpty());
|
||
QCOMPARE(refus, QString("UNIT_Wh"));
|
||
|
||
// 4. CHARGE UTILE VIDE ou tronquée — refus nommé, jamais une courbe de zéros.
|
||
QVERIFY(ForecastStore::depuisTransport(QVariantMap(), refus).points.isEmpty());
|
||
QCOMPARE(refus, QString("EMPTY"));
|
||
QVERIFY(ForecastStore::depuisTransport(charge(1, "W", QVariantList()), refus).points.isEmpty());
|
||
QCOMPARE(refus, QString("MALFORMED"));
|
||
|
||
// 5. UN CRÉNEAU ILLISIBLE EST UN TROU, pas un zéro : la courbe en porte deux au lieu de
|
||
// trois, et l'absence reste l'information.
|
||
const ForecastRevision troue = ForecastStore::depuisTransport(
|
||
charge(1, "W", QVariantList{100, QString("bruit"), 300}), refus);
|
||
QVERIFY(refus.isEmpty());
|
||
QCOMPARE(troue.points.count(), 2);
|
||
}
|
||
|
||
void TestLoadModel::testFreshnessNeverDatesTheForecast()
|
||
{
|
||
QTemporaryDir dir;
|
||
ForecastStore magasin(dir.filePath("forecast.sqlite"));
|
||
QVERIFY(magasin.isValid());
|
||
|
||
const QDateTime run06 = QDateTime(QDate(2026, 9, 9), QTime(6, 0), QTimeZone::utc());
|
||
const QDateTime run10 = QDateTime(QDate(2026, 9, 9), QTime(10, 0), QTimeZone::utc());
|
||
const QDateTime t0 = QDateTime(QDate(2026, 9, 9), QTime(14, 0), QTimeZone::utc());
|
||
|
||
QVERIFY(magasin.enregistrer(revision(run06, t0, {800, 900})));
|
||
|
||
// 1. SUR UNE LIGNE JAMAIS RECONDUITE, la fraîcheur vaut l'émission — jamais absente.
|
||
// Ce n'est pas une copie déguisée en confirmation : le champ répond à « quel est le
|
||
// dernier run qui a AFFIRMÉ ces valeurs », et le run émetteur les a affirmées.
|
||
// L'absence, elle, se lirait « on ne sait pas jusqu'à quand elle a tenu » — c'est faux,
|
||
// et chaque client devrait écrire le repli à sa façon.
|
||
QList<ForecastPoint> vue = magasin.courbe(t0, t0.addSecs(3600), run06.addSecs(60));
|
||
QCOMPARE(vue.count(), 2);
|
||
QVERIFY2(vue.first().lastConfirmedAt.isValid(), "la fraîcheur n'est JAMAIS absente");
|
||
QCOMPARE(vue.first().lastConfirmedAt, run06);
|
||
QCOMPARE(vue.first().firstIssuedAt, run06);
|
||
|
||
// 2. Après reconduction, elle avance — et l'identité, NON.
|
||
QVERIFY(!magasin.enregistrer(revision(run10, t0, {800, 900})));
|
||
vue = magasin.courbe(t0, t0.addSecs(3600), run10.addSecs(60));
|
||
QCOMPARE(vue.first().lastConfirmedAt, run10); // fraîcheur : avancée
|
||
QCOMPARE(vue.first().firstIssuedAt, run06); // identité : IMMUABLE
|
||
|
||
// 3. L'INVARIANT qui rend le couple lisible : la fraîcheur ne précède jamais l'émission.
|
||
for (const ForecastPoint &p : vue)
|
||
QVERIFY2(p.lastConfirmedAt >= p.firstIssuedAt,
|
||
"une prévision ne peut pas avoir tenu AVANT d'avoir été émise");
|
||
|
||
// 4. LA FRAÎCHEUR NE DATE PAS LA PRÉVISION — c'est la garde que la règle demande. `revisions()`
|
||
// rend les instants d'ÉMISSION : deux prévisions différentes reconduites par le même run
|
||
// porteraient sinon la même date, et l'historique deviendrait illisible.
|
||
const QList<QPair<QDateTime, double>> revs = magasin.revisions(t0);
|
||
QCOMPARE(revs.count(), 1);
|
||
QCOMPARE(revs.first().first, run06); // l'émission, pas la reconduction
|
||
}
|