// 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 #include #include #include #include #include #include namespace { //! Une config relay-router minimale, valide, dont on ne fait varier que les relais. LoadConfig relayLoad(const QList> &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{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{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{K1}); QCOMPARE(b.mapping.at(1), QList{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> 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 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> 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")); }