// SPDX-License-Identifier: GPL-3.0-or-later #include "testloadmodel.h" #include "etm/adapters/relayrouter.h" #include "etm/energyarbitrator.h" #include "etm/types/loadconfig.h" #include "etm/config/loadconfigstore.h" #include 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"); }