// SPDX-License-Identifier: GPL-3.0-or-later #include "etm/forecast/forecaststore.h" #include "etm/forecast/forecastlearninglog.h" #include #include #include #include #include #include #include #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 "etm/tariff/tariffconfig.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::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 : créneaux consécutifs à partir de \a depuis, une valeur par créneau. ForecastRevision revision(const QDateTime &age, const QDateTime &depuis, const QList &wh, const QString &site = QStringLiteral("site-A")) { ForecastRevision r; r.issuedAt = age; // ATTRIBUT, jamais la clé r.siteId = site; for (int i = 0; i < wh.count(); ++i) { ForecastPoint p; p.targetAt = depuis.addSecs(i * 3600); p.wh = wh.at(i); r.points.append(p); } return r; } } void TestLoadModel::testTheContentIsTheRevisionIdentity() { // \rule{LM-1500} n'a pas bougé : « une révision n'existe que si la PRÉVISION a changé ». // C'est la CLÉ qui a changé, parce que la prémisse a été mesurée fausse le 2026-09-09 : // `issuedAt` vient d'un domaine qui ne fournit AUCUNE valeur de la courbe, et le serveur // recompose les premières heures sans le faire bouger. Regarder le contenu est donc PLUS // fidèle à la règle que regarder l'étiquette d'émission. QTemporaryDir dir; ForecastStore magasin(dir.filePath("forecast.sqlite")); QVERIFY(magasin.isValid()); const QDateTime run00 = QDateTime(QDate(2026, 9, 9), QTime(0, 0), QTimeZone::utc()); const QDateTime t0 = QDateTime(QDate(2026, 9, 9), QTime(10, 0), QTimeZone::utc()); QVERIFY(magasin.enregistrer(revision(run00, t0, {100, 200, 300}))); QCOMPARE(magasin.revisions(t0).count(), 1); // 1. MÊME CONTENU republié — pas une révision, quoi qu'ait fait l'âge. La comparaison porte // sur la courbe, pas sur la charge utile : deux charges utiles différentes portant la // même courbe sont la même prévision, ce qu'aucune égalité de charge utile ne peut voir. QVERIFY(!magasin.enregistrer(revision(run00.addSecs(6 * 3600), t0, {100, 200, 300}))); QCOMPARE(magasin.revisions(t0).count(), 1); // 2. LE CŒUR DE LA CORRECTION — un contenu NEUF sous un `issuedAt` INCHANGÉ **EST** une // révision. La version précédente la refusait : elle gelait la courbe sur les valeurs du // premier cycle, et l'écran aurait montré une prévision périmée sans le dire — un // instrument qui continue d'indiquer une ancienne mesure, pire que pas d'instrument. QTest::qWait(5); QVERIFY2(magasin.enregistrer(revision(run00, t0, {100, 168, 300})), "un contenu neuf sous le MÊME issuedAt est une révision — sinon la courbe gèle"); QCOMPARE(magasin.revisions(t0).count(), 2); const QList vue = magasin.courbe(t0, t0.addSecs(2 * 3600)); QCOMPARE(qRound(vue.at(1).wh), 168); // la valeur FRAÎCHE, pas celle de 06:03 // 3. UN ÂGE INCONNU N'EST PLUS UN REFUS. `best_match` publie `issuedAt = 0` : jeter une // prévision utilisable parce qu'un ATTRIBUT manque, ce serait jeter la donnée pour son // étiquette. QTest::qWait(5); QVERIFY2(magasin.enregistrer(revision(QDateTime(), t0, {111, 222, 333})), "une courbe sans âge connu reste enregistrable"); QCOMPARE(magasin.revisions(t0).count(), 3); // 4. DEUX SITES NE SE MÊLENT PAS. Sans le site dans la clé, changer de site désigné // mêlerait deux historiques que rien ne distinguerait plus — et deux plans publient // des courbes différentes sous le même issuedAt, donc le besoin est réel. QTest::qWait(5); QVERIFY(magasin.enregistrer(revision(run00, t0, {100, 200, 300}, QStringLiteral("site-B")))); QCOMPARE(magasin.revisions(t0).count(), 4); // la même courbe, mais d'un AUTRE site // 5. Une révision VIDE n'en est pas une : l'absence est un trou, pas une courbe de zéros. ForecastRevision vide; vide.issuedAt = run00; vide.siteId = QStringLiteral("site-A"); 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()); QVERIFY(magasin.enregistrer(revision(t0, t0, {1000, 1100, 1200}))); const QDateTime avant = QDateTime::currentDateTimeUtc(); QTest::qWait(5); // les instants d'OBSERVATION sont en ms QVERIFY(magasin.enregistrer(revision(t0, t0, {700, 800, 900}))); // « CE QU'ON PRÉVOYAIT À TEL MOMENT » se demande 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 tot = magasin.courbe(t0, t0.addSecs(7200), avant); QCOMPARE(tot.count(), 3); QCOMPARE(qRound(tot.first().wh), 1000); const QList tard = magasin.courbe(t0, t0.addSecs(7200)); QCOMPARE(qRound(tard.first().wh), 700); // Les DEUX révisions coexistent pour le même créneau — c'est l'objet de la table. QCOMPARE(magasin.revisions(t0).count(), 2); // LES TROUS RESTENT DES TROUS : six heures demandées, trois connues, trois rendues. Jamais // six dont trois à zéro — un zéro se lirait « on prévoit une nuit ». QCOMPARE(magasin.courbe(t0, t0.addSecs(6 * 3600)).count(), 3); QVERIFY(magasin.courbe(t0.addDays(5), t0.addDays(5).addSecs(7200)).isEmpty()); // La FRAÎCHEUR n'est jamais absente, et jamais antérieure à l'apparition. for (const ForecastPoint &p : tard) { QVERIFY(p.lastSeenAt.isValid()); QVERIFY2(p.lastSeenAt >= p.firstSeenAt, "une prévision ne peut pas avoir été revue avant d'être apparue"); } } void TestLoadModel::testRetentionKeepsBothEndsOfEachRevision() { QTemporaryDir dir; ForecastStore magasin(dir.filePath("forecast.sqlite")); QVERIFY(magasin.isValid()); const QDateTime cible = QDateTime(QDate(2026, 9, 1), QTime(12, 0), QTimeZone::utc()); for (int i = 0; i < 4; ++i) { QVERIFY(magasin.enregistrer(revision(cible, cible, {1000.0 + i}))); QTest::qWait(5); // quatre apparitions distinctes } QCOMPARE(magasin.revisions(cible).count(), 4); // Purge bien au-delà de la fenêtre pleine : seuls les DEUX BOUTS survivent — la première // apparition et la dernière. Les deux répondent aux deux seules questions qui se posent ; // n'en garder qu'une détruirait la révision, c'est-à-dire l'objet de la table. QCOMPARE(magasin.purger(cible.addDays(30)), 2); const QList> restant = magasin.revisions(cible); QCOMPARE(restant.count(), 2); QCOMPARE(qRound(restant.first().second), 1000); QCOMPARE(qRound(restant.last().second), 1003); // CONTRE-ÉPREUVE — un créneau RÉCENT garde toutes ses révisions. Sans elle, une purge qui // viderait tout passerait pour une purge correcte. const QDateTime recent = QDateTime::currentDateTimeUtc().addSecs(3600); for (int i = 0; i < 3; ++i) { QVERIFY(magasin.enregistrer(revision(recent, recent, {500.0 + i}))); QTest::qWait(5); } magasin.purger(); QCOMPARE(magasin.revisions(recent).count(), 3); } void TestLoadModel::testAPayloadIsRefusedOnFormNeverOnAMissingAttribute() { // La charge utile traverse une frontière de PROCESSUS : aucun compilateur ne vérifie sa // forme. Mais la FORME et les ATTRIBUTS ne se refusent pas de la même façon — refuser sur un // attribut manquant jetterait la donnée pour son étiquette. const qint64 t0 = QDateTime(QDate(2026, 9, 9), QTime(0, 0), QTimeZone::utc()).toSecsSinceEpoch(); auto charge = [&](int v, const QString &unite, qint64 age, const QVariantList &p) { return QVariantMap{{"v", v}, {"t0", t0}, {"step", 3600}, {"unit", unite}, {"convention", "mean over [t, t+step)"}, {"model", "meteofrance_seamless"}, {"issuedAt", age}, {"p", p}}; }; QString refus; // 1. NOMINAL — la conversion suit la convention LUE : p[i] est une moyenne en W sur le // créneau, donc l'énergie vaut w x step / 3600, sans supposer que le pas vaut une heure. const ForecastRevision ok = ForecastStore::depuisTransport( charge(1, "W", t0, QVariantList{0, 250, 1200}), refus); QVERIFY2(refus.isEmpty(), qUtf8Printable(refus)); QCOMPARE(ok.points.count(), 3); QCOMPARE(qRound(ok.points.at(1).wh), 250); QCOMPARE(ok.points.at(1).targetAt.toSecsSinceEpoch(), t0 + 3600); // 2. LA FORME se refuse, et le motif la NOMME. // (v2 est LUE depuis le brief 3 T1 : la version inconnue est désormais la 3.) QVERIFY(ForecastStore::depuisTransport(charge(3, "W", t0, QVariantList{0, 250}), refus) .points.isEmpty()); QCOMPARE(refus, QString("VERSION_3")); QVERIFY(ForecastStore::depuisTransport(charge(1, "Wh", t0, QVariantList{0, 250}), refus) .points.isEmpty()); QCOMPARE(refus, QString("UNIT_Wh")); // lire des Wh comme des W : plausible et faux QVERIFY(ForecastStore::depuisTransport(QVariantMap(), refus).points.isEmpty()); QCOMPARE(refus, QString("EMPTY")); QVERIFY(ForecastStore::depuisTransport(charge(1, "W", t0, QVariantList()), refus).points.isEmpty()); QCOMPARE(refus, QString("MALFORMED")); // 3. UN ATTRIBUT MANQUANT NE SE REFUSE PAS. `best_match` publie `issuedAt = 0` : la courbe // est parfaitement exploitable, seul son ÂGE est inconnu — et un âge inconnu se dit par // une absence, jamais par un refus de la donnée qu'il accompagne. const ForecastRevision sansAge = ForecastStore::depuisTransport( charge(1, "W", 0, QVariantList{10, 20, 30}), refus); QVERIFY2(refus.isEmpty(), "un âge inconnu ne refuse pas la courbe"); QCOMPARE(sansAge.points.count(), 3); QVERIFY2(!sansAge.issuedAt.isValid(), "et aucun instant n'est INVENTÉ à sa place"); // 4. UN CRÉNEAU ILLISIBLE EST UN TROU, pas un zéro. const ForecastRevision troue = ForecastStore::depuisTransport( charge(1, "W", t0, QVariantList{100, QString("bruit"), 300}), refus); QVERIFY(refus.isEmpty()); QCOMPARE(troue.points.count(), 2); } void TestLoadModel::testTransportV2IsReadAndAnUnknownCorrectionRefused() { // Brief 3 T1 — `ems` lit v1 ET v2 (prérequis du déploiement d'openmeteo v2). v2 garde tous // les champs v1 et ajoute `corrections` (liste), `gti[]`, `tempC[]`. const qint64 t0 = QDateTime(QDate(2026, 10, 25), QTime(0, 0), QTimeZone::utc()).toSecsSinceEpoch(); const QVariantList p{0, 412.5, 1768.25}; auto v1 = [&]() { return QVariantMap{{"v", 1}, {"t0", t0}, {"step", 3600}, {"unit", "W"}, {"convention", "mean over [t, t+step)"}, {"regime", "x"}, {"model", "meteofrance_seamless"}, {"issuedAt", t0}, {"p", p}, {"corrections", "none"}}; }; auto v2 = [&](const QVariant &corrections) { QVariantMap m = v1(); m["v"] = 2; if (corrections.isValid()) m["corrections"] = corrections; else m.remove("corrections"); m["gti"] = QVariantList{0, 500, 1000}; m["tempC"] = QVariantList{5.0, 12.5, QVariant()}; // un null : T2 le lira, pas T1 return m; }; QString refus; // 1. v1 : lue comme avant, corrections VIDES — la v1 n'a jamais corrigé, par contrat. const ForecastRevision a = ForecastStore::depuisTransport(v1(), refus); QVERIFY2(refus.isEmpty(), qUtf8Printable(refus)); QVERIFY(a.corrections.isEmpty()); QCOMPARE(a.stepS, 3600); // 2. v2 sans correction : MÊME courbe que v1, au bit près (le brief 2 l'exige du producteur ; // ici, que le lecteur ne la déforme pas). const ForecastRevision b = ForecastStore::depuisTransport(v2(QVariantList()), refus); QVERIFY2(refus.isEmpty(), qUtf8Printable(refus)); QVERIFY(b.corrections.isEmpty()); QCOMPARE(b.points.count(), a.points.count()); for (int i = 0; i < a.points.count(); ++i) { QCOMPARE(b.points.at(i).targetAt, a.points.at(i).targetAt); QVERIFY(b.points.at(i).wh == a.points.at(i).wh); } // 3. v2 corrigée en température : lue, et la correction est DITE. const ForecastRevision c = ForecastStore::depuisTransport(v2(QVariantList{"temperature"}), refus); QVERIFY2(refus.isEmpty(), qUtf8Printable(refus)); QCOMPARE(c.corrections, QStringList{"temperature"}); // 4. Les REFUS, chacun nommé — jamais une lecture au mieux. QVERIFY(ForecastStore::depuisTransport(v2(QVariant()), refus).points.isEmpty()); QCOMPARE(refus, QString("MALFORMED")); // corrections absente en v2 QVERIFY(ForecastStore::depuisTransport(v2(QString("none")), refus).points.isEmpty()); QCOMPARE(refus, QString("MALFORMED")); // la chaîne v1 n'est plus une forme v2 QVERIFY(ForecastStore::depuisTransport(v2(QVariantList{"temperature", "soiling"}), refus) .points.isEmpty()); QCOMPARE(refus, QString("CORRECTION_soiling")); // un effet qu'on ne sait pas nommer QVERIFY(ForecastStore::depuisTransport(v2(QVariantList{"ac-limit"}), refus).points.isEmpty()); QCOMPARE(refus, QString("CORRECTION_ac-limit")); // le plafond est au SITE, pas au plan QVariantMap sansV = v1(); sansV.remove("v"); QVERIFY(ForecastStore::depuisTransport(sansV, refus).points.isEmpty()); QCOMPARE(refus, QString("VERSION_")); // v absent : jamais deviné } void TestLoadModel::testTheSiteCurveIsClippedAfterTheSum() { // Brief 3 T1 — plafond AC de SITE, appliqué APRÈS la somme des plans (§28 décision 2). const QDateTime t0(QDate(2026, 10, 12), QTime(10, 0), QTimeZone::utc()); auto plan = [&](const QList &w, const QStringList &corr, int step = 3600, const QDateTime &run = QDateTime(QDate(2026, 10, 12), QTime(6, 0), QTimeZone::utc())) { ForecastRevision r; r.issuedAt = run; r.stepS = step; r.corrections = corr; r.model = "m"; for (int i = 0; i < w.count(); ++i) { if (w.at(i) < 0) continue; // < 0 : créneau ABSENT du plan ForecastPoint p; p.targetAt = t0.addSecs(qint64(i) * step); p.wh = w.at(i) * step / 3600.0; r.points << p; } return r; }; const QStringList T{"temperature"}; const QList deux{plan({1500, 800, -1}, T), plan({1200, 300, 900}, T)}; // 1. SANS plafond : la somme, rien d'autre, et pas d'`ac-limit` annoncé. ForecastRevision s = ForecastStore::sommerPlans(deux, std::nullopt); QCOMPARE(s.points.count(), 3); QCOMPARE(s.points.at(0).wh, 2700.0); QCOMPARE(s.corrections, T); // 2. AVEC plafond 2000 W : 2700 → 2000 ; 1100 intact ; et le plafond porte sur la SOMME — // aucun des deux plans ne dépasse 2000 seul, l'écrêtage par plan n'aurait rien retiré. bool melangees = true; s = ForecastStore::sommerPlans(deux, 2000, &melangees); QCOMPARE(s.points.count(), 3); QCOMPARE(s.points.at(0).wh, 2000.0); QCOMPARE(s.points.at(1).wh, 1100.0); QCOMPARE(s.points.at(2).wh, 900.0); // présent dans UN plan : la somme de ce qui existe QCOMPARE(s.corrections, (QStringList{"temperature", "ac-limit"})); QVERIFY(!melangees); // 3. Le plafond suit le PAS lu : au quart d'heure, 2000 W font 500 Wh. s = ForecastStore::sommerPlans({plan({3000}, {}, 900)}, 2000); QCOMPARE(s.points.at(0).wh, 500.0); QCOMPARE(s.corrections, QStringList{"ac-limit"}); // 4. Un créneau absent RESTE absent : le plafond ne fabrique pas de point (jamais 0). s = ForecastStore::sommerPlans({plan({-1, 400}, {})}, 2000); QCOMPARE(s.points.count(), 1); QCOMPARE(s.points.at(0).targetAt, t0.addSecs(3600)); // 5. Corrections DIVERGENTES : la plus faible commune, et c'est signalé. s = ForecastStore::sommerPlans({plan({100}, T), plan({100}, {})}, std::nullopt, &melangees); QVERIFY(s.corrections.isEmpty()); QVERIFY(melangees); s = ForecastStore::sommerPlans({plan({100}, T), plan({100}, {})}, 5000, &melangees); QCOMPARE(s.corrections, QStringList{"ac-limit"}); // 6. Runs ou pas différents : RIEN, plutôt qu'un mélange. QVERIFY(ForecastStore::sommerPlans({plan({100}, T), plan({100}, T, 3600, t0)}, 2000).points.isEmpty()); QVERIFY(ForecastStore::sommerPlans({plan({100}, T), plan({100}, T, 900)}, 2000).points.isEmpty()); QVERIFY(ForecastStore::sommerPlans({}, 2000).points.isEmpty()); } void TestLoadModel::testTheCurrentCurveIsTheLastSeenNotTheLastNew() { // Cas mordu sur .75 le 2026-09-09 : un plan de test ajouté puis retiré fait RÉAPPARAÎTRE un // contenu déjà connu. Son firstSeenAt ne bouge pas — c'est le principe du champ — si bien // qu'un tri sur MAX(firstSeenAt) continuait de servir la courbe de l'installation FICTIVE // quatre minutes après sa disparition. QTemporaryDir dir; ForecastStore magasin(dir.filePath("forecast.sqlite")); QVERIFY(magasin.isValid()); const QDateTime t0 = QDateTime(QDate(2026, 9, 9), QTime(10, 0), QTimeZone::utc()); QVERIFY(magasin.enregistrer(revision(t0, t0, {100, 200}))); // A — un plan QTest::qWait(5); QVERIFY(magasin.enregistrer(revision(t0, t0, {180, 360}))); // B — deux plans (fiction) QTest::qWait(5); QCOMPARE(qRound(magasin.courbe(t0, t0.addSecs(3600)).first().wh), 180); // B a cours // LE RETOUR À A : contenu déjà connu, donc AUCUNE ligne neuve — seule la fraîcheur avance. QVERIFY2(!magasin.enregistrer(revision(t0, t0, {100, 200})), "un contenu déjà connu ne crée pas de révision, même en revenant"); QCOMPARE(magasin.revisions(t0).count(), 2); // ET C'EST A QUI A COURS, parce qu'il a été vu EN DERNIER. Avec un tri sur l'apparition, B // resterait servi indéfiniment : une courbe qui ne décrit plus rien, sans que rien ne le dise. const QList vue = magasin.courbe(t0, t0.addSecs(3600)); QCOMPARE(vue.count(), 2); QCOMPARE(qRound(vue.first().wh), 100); // L'apparition de A reste la PREMIÈRE : le retour ne réécrit pas son identité. QVERIFY2(vue.first().firstSeenAt < vue.first().lastSeenAt, "revenir à un contenu avance sa fraîcheur, jamais son apparition"); } // ============================================================================================ // Brief 3 T2 — l'enregistrement pour l'apprentissage futur // ============================================================================================ namespace { const QTimeZone Paris("Europe/Paris"); QDateTime utc(int y, int mo, int d, int h, int mi = 0, int s = 0) { return QDateTime(QDate(y, mo, d), QTime(h, mi, s), QTimeZone::utc()); } //! Lit la table par une connexion À NOUS, pas celle du fil d'écriture : on vérifie ce qui est //! sur le disque, pas ce que l'écrivain croit avoir écrit. QList lignesApprentissage(const QString &chemin, const QString &where = QString()) { QList sortie; const QString nom = QStringLiteral("lecture-%1").arg(QUuid::createUuid().toString()); { QSqlDatabase db = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), nom); db.setDatabaseName(chemin); if (db.open()) { QSqlQuery q(db); if (q.exec(QStringLiteral("SELECT * FROM forecastLearning %1 ORDER BY targetAt, planId") .arg(where.isEmpty() ? QString() : QStringLiteral("WHERE ") + where))) { while (q.next()) { QVariantMap m; const QSqlRecord r = q.record(); for (int i = 0; i < r.count(); ++i) m.insert(r.fieldName(i), q.value(i)); sortie << m; } } db.close(); } } QSqlDatabase::removeDatabase(nom); return sortie; } bool executer(const QString &chemin, const QStringList &requetes) { const QString nom = QStringLiteral("ecriture-%1").arg(QUuid::createUuid().toString()); bool ok = true; { QSqlDatabase db = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), nom); db.setDatabaseName(chemin); ok = db.open(); QSqlQuery q(db); for (const QString &r : requetes) ok = ok && q.exec(r); db.close(); } QSqlDatabase::removeDatabase(nom); return ok; } //! Une observation de plan, telle que l'arbitre la remet : charge utile + paramètres du Thing. ForecastPlanObservation planObserve(const QString &id, int v, const QDateTime &t0, const QVariantList &p, const QVariantMap ¶ms) { QVariantMap c{{"v", v}, {"t0", t0.toSecsSinceEpoch()}, {"step", 3600}, {"unit", "W"}, {"convention", "mean over [t, t+step)"}, {"model", "meteofrance_seamless"}, {"issuedAt", t0.toSecsSinceEpoch() - 3600}, {"p", p}}; if (v >= 2) { c["corrections"] = QVariantList{"temperature"}; QVariantList gti, temp; for (int i = 0; i < p.count(); ++i) { gti << 100.0 * (i + 1); temp << 10.0 + i; } temp[1] = QVariant(); // un trou : NULL, jamais 0 c["gti"] = gti; c["tempC"] = temp; } QString refus; ForecastPlanObservation o = ForecastLearningLog::lirePlan(c, params, refus); o.planId = id; return o; } //! Injecte une minute de production par minute sur ]de, a] — convention nymea, NÉGATIVE. void minutes(ForecastLearningLog &log, const QDateTime &de, const QDateTime &a, double produitW) { for (QDateTime t = de.addSecs(60); t <= a; t = t.addSecs(60)) log.observerProduction(t, -produitW); } } void TestLoadModel::testTheLearningLogWritesOneRowPerElapsedStepAndPlan() { QTemporaryDir dir; const QString chemin = dir.filePath("energy-forecast.sqlite"); ForecastStore magasin(chemin); // même fichier, comme en production QVERIFY(magasin.isValid()); ForecastLearningLog log(chemin, Paris); const QDateTime t0 = utc(2026, 10, 13, 9); // grille : 09:00, 10:00, 11:00 const QVariantMap params{{"kwp", 2.0}, {"performanceRatio", 0.9}, {"tempCoeffPctPerK", -0.37}, {"noctC", 45.0}}; const QList plans{ planObserve("{plan-v2}", 2, t0, {300, 1200, 1500}, params), planObserve("{plan-v1}", 1, t0, {250, 1000, 1400}, params), // MÊMES paramètres posés }; QCOMPARE(plans.at(0).pW.count(), 3); QCOMPARE(plans.at(1).pW.count(), 3); const QString site = QStringLiteral("{site}"); // Premier cycle à 10:05 : le pas de 09:00 est ÉCOULÉ AVANT qu'on regarde — jamais écrit. log.observer(utc(2026, 10, 13, 10, 5), site, plans, 2, 3000, std::nullopt, true); minutes(log, utc(2026, 10, 13, 10), utc(2026, 10, 13, 11), 1500); log.observer(utc(2026, 10, 13, 10, 40), site, plans, 2, 3000, std::nullopt, true); // 11:01 : le pas est fini, mais la grâce (dernière minute en route) ne l'est pas. log.observer(utc(2026, 10, 13, 11, 1), site, plans, 2, 3000, std::nullopt, true); QCOMPARE(log.lotsTraites(), 0); log.observer(utc(2026, 10, 13, 11, 3), site, plans, 2, 3000, std::nullopt, true); QTRY_COMPARE(log.lotsTraites(), 1); QCOMPARE(log.lignesEcrites(), 2); const QList l = lignesApprentissage(chemin); QCOMPARE(l.count(), 2); // UN pas, DEUX plans — rien d'autre const QVariantMap v1 = l.at(0), v2 = l.at(1); // tri par planId : v1 < v2 QCOMPARE(v1.value("planId").toString(), QString("{plan-v1}")); QCOMPARE(v2.value("planId").toString(), QString("{plan-v2}")); for (const QVariantMap &r : l) { QCOMPARE(r.value("targetAt").toLongLong(), qint64(1791885600)); // oracle hors Qt QCOMPARE(r.value("stepS").toInt(), 3600); QCOMPARE(r.value("siteId").toString(), site); QCOMPARE(r.value("kwp").toDouble(), 2.0); QCOMPARE(r.value("performanceRatio").toDouble(), 0.9); QCOMPARE(r.value("acLimitW").toInt(), 3000); QCOMPARE(r.value("measuredW").toDouble(), 1500.0); // positive : le site PRODUIT QCOMPARE(r.value("measuredSource").toString(), QString("power-balance-1min")); QCOMPARE(r.value("measuredMinutes").toInt(), 60); QCOMPARE(r.value("curtailed").toInt(), 0); // plafond déclaré, jamais atteint QVERIFY2(r.value("snow").isNull(), "aucune lecture de neige : NULL, pas « pas de neige »"); QCOMPARE(r.value("sitePlans").toInt(), 2); QCOMPARE(r.value("sitePlansExpected").toInt(), 2); QCOMPARE(r.value("valid").toInt(), 1); // J−1 18:00 à PARIS pour un pas de 10:00Z le 13 : le 12 à 16:00Z (oracle hors Qt). QCOMPARE(r.value("dayAheadAt").toLongLong(), qint64(1791820800)); QCOMPARE(r.value("issuedAt").toLongLong(), t0.toSecsSinceEpoch() - 3600); QCOMPARE(r.value("observedAt").toLongLong(), utc(2026, 10, 13, 11, 3).toMSecsSinceEpoch()); } // v2 : la valeur DU PAS (index 1), pas celle d'un voisin. QCOMPARE(v2.value("pW").toDouble(), 1200.0); QCOMPARE(v2.value("gti").toDouble(), 200.0); QVERIFY2(v2.value("tempC").isNull(), "un tempC null dans la charge utile reste NULL"); QCOMPARE(v2.value("corrections").toString(), QString("temperature")); QCOMPARE(v2.value("transportV").toInt(), 2); QCOMPARE(v2.value("tempCoeffPctPerK").toDouble(), -0.37); QCOMPARE(v2.value("noctC").toDouble(), 45.0); // v1 : gti, tempC et paramètres v2 NULL — JAMAIS 0 — alors que le Thing les porte. QCOMPARE(v1.value("pW").toDouble(), 1000.0); QCOMPARE(v1.value("transportV").toInt(), 1); QCOMPARE(v1.value("corrections").toString(), QString()); for (const char *c : {"gti", "tempC", "tempCoeffPctPerK", "noctC"}) QVERIFY2(v1.value(c).isNull(), c); // Le pas de 11:00 n'est pas écoulé : rien. QCOMPARE(lignesApprentissage(chemin, "targetAt = 1791889200").count(), 0); // Les cycles SUIVANTS revoient le pas de 10:00 dans la charge utile — elle garde les heures // passées du jour. Un fait écrit ne se reprend pas : aucun lot, aucune ligne de journal par // minute. (Trouvé par le test de la table abîmée : sans filigrane, chaque cycle le réécrivait.) log.observer(utc(2026, 10, 13, 11, 10), site, plans, 2, 3000, std::nullopt, true); log.observer(utc(2026, 10, 13, 11, 20), site, plans, 2, 3000, std::nullopt, true); QTest::qWait(100); QCOMPARE(log.lotsTraites(), 1); // Un SECOND enregistreur sur le même fichier — un redémarrage qui revoit la même heure — // ne réécrit pas le fait clos : la première écriture, qui a vu le pas, l'emporte. { ForecastLearningLog relance(chemin, Paris); QList autres = plans; autres[0].pW[1] = 9999.0; relance.observer(utc(2026, 10, 13, 10, 59), site, autres, 2, 3000, std::nullopt, true); relance.observer(utc(2026, 10, 13, 11, 3), site, autres, 2, 3000, std::nullopt, true); QTRY_COMPARE(relance.lotsTraites(), 1); QCOMPARE(relance.lignesEcrites(), 0); } QCOMPARE(lignesApprentissage(chemin, "planId = '{plan-v2}'").first().value("pW").toDouble(), 1200.0); } void TestLoadModel::testTheLearningLogReadsMeasureCurtailmentAndSnow() { const QDateTime t0 = utc(2026, 10, 13, 10); const QVariantMap params{{"kwp", 2.0}, {"performanceRatio", 0.9}}; struct Cas { const char *nom; int minutesRecues; // minutes injectées à partir de 10:01 double produitW; // production de chaque minute double picW; // production de la minute 10:30 (le pic) std::optional plafond; std::optional neige; bool producteur; int plansVus, plansAttendus; // attendus bool mesure; QVariant curtailed; QVariant snow; int valid; }; const QList cas{ // couverture complète, plafond non atteint : la référence {"nominal", 60, 1200, 1200, 3000, 0.0, true, 1, 1, true, 0, 0, 1}, // 30 minutes sur 60 : pas de moyenne, et l'écrêtage n'a pas pu être cherché {"couverture partielle", 30, 1200, 1200, 3000, 0.0, true, 1, 1, false, {}, 0, 0}, // 54 minutes sur 60 = 90 % : publiée, avec le compte {"couverture au seuil", 54, 1200, 1200, 3000, 0.0, true, 1, 1, true, 0, 0, 1}, // une minute à 98 % du plafond : l'onduleur bornait, la mesure sous-estime {"écrêtage détecté", 60, 1200, 2941, 3000, 0.0, true, 1, 1, true, 1, 0, 0}, // juste sous le seuil : rien de DÉTECTABLE {"sous le seuil", 60, 1200, 2930, 3000, 0.0, true, 1, 1, true, 0, 0, 1}, // sans plafond déclaré, rien n'est cherché : NULL, jamais « non écrêté » {"sans plafond", 60, 1200, 2999, {}, 0.0, true, 1, 1, true, {}, 0, 1}, // sans producteur, la « production » est un zéro de construction : NULL {"sans producteur", 60, 1200, 1200, 3000, 0.0, false, 1, 1, false, {}, 0, 0}, // une chute de neige lue pendant le pas {"neige", 60, 1200, 1200, 3000, 0.4, true, 1, 1, true, 0, 1, 0}, // le site n'a pas d'état snowfall : NULL {"neige non lue", 60, 1200, 1200, 3000, {}, true, 1, 1, true, 0, {}, 1}, // un plan du site refusé : la somme mesurée n'est pas comparable à ce plan seul {"plan manquant", 60, 1200, 1200, 3000, 0.0, true, 1, 2, true, 0, 0, 0}, }; for (const Cas &c : cas) { QTemporaryDir dir; const QString chemin = dir.filePath("energy-forecast.sqlite"); ForecastLearningLog log(chemin, Paris); const QList plans{ planObserve("{p}", 2, t0, {1000, 1000}, params)}; log.observer(t0.addSecs(30), "{s}", plans, c.plansAttendus, c.plafond, c.neige, c.producteur); for (int m = 1; m <= c.minutesRecues; ++m) log.observerProduction(t0.addSecs(60 * m), -(m == 30 ? c.picW : c.produitW)); log.observer(t0.addSecs(1800), "{s}", plans, c.plansAttendus, c.plafond, c.neige, c.producteur); log.observer(t0.addSecs(3600 + 150), "{s}", plans, c.plansAttendus, c.plafond, std::nullopt, c.producteur); QTRY_COMPARE(log.lotsTraites(), 1); const QList l = lignesApprentissage(chemin); QVERIFY2(l.count() == 1, c.nom); const QVariantMap r = l.first(); QVERIFY2(r.value("measuredMinutes").toInt() == c.minutesRecues, c.nom); QVERIFY2(r.value("measuredW").isNull() == !c.mesure, c.nom); QVERIFY2(r.value("measuredSource").isNull() == !c.mesure, c.nom); QVERIFY2(r.value("curtailed").isNull() == c.curtailed.isNull() && (c.curtailed.isNull() || r.value("curtailed").toInt() == c.curtailed.toInt()), qPrintable(QStringLiteral("%1 : curtailed %2").arg(c.nom, r.value("curtailed").toString()))); QVERIFY2(r.value("snow").isNull() == c.snow.isNull() && (c.snow.isNull() || r.value("snow").toInt() == c.snow.toInt()), c.nom); QVERIFY2(r.value("valid").toInt() == c.valid, c.nom); QVERIFY2(r.value("sitePlans").toInt() == c.plansVus, c.nom); // Une v2 dont le Thing ne porte PAS encore tempCoeff/noct (openmeteo d'avant le brief 2) : // NULL, jamais un défaut inventé ici. QVERIFY2(r.value("tempCoeffPctPerK").isNull() && r.value("noctC").isNull(), c.nom); QVERIFY2(r.value("kwp").toDouble() == 2.0, c.nom); } } void TestLoadModel::testTheLearningLogPointsAtTheRevisionsInForce() { // La table RENVOIE aux révisions du site par leur CLÉ DE CONTENU (§20), jamais par // `issuedAt`. Les révisions sont posées à la main, avec des instants d'observation choisis : // c'est la règle « en vigueur à X » qu'on éprouve, pas l'horloge. QTemporaryDir dir; const QString chemin = dir.filePath("energy-forecast.sqlite"); { ForecastStore magasin(chemin); QVERIFY(magasin.isValid()); } // crée forecastPv const QString site = QStringLiteral("{site}"); const qint64 pas10 = utc(2026, 10, 13, 10).toSecsSinceEpoch(); const qint64 pas9 = utc(2026, 10, 13, 9).toSecsSinceEpoch(); auto ms = [](const QDateTime &d) { return QString::number(d.toMSecsSinceEpoch()); }; auto rev = [&](qint64 cible, const QString &id, const QDateTime &premier, const QDateTime &dernier, const QString &s = QStringLiteral("{site}")) { return QStringLiteral("INSERT INTO forecastPv (targetAt, siteId, contentId, firstSeenAt, " "lastSeenAt, forecastWh) VALUES (%1, '%2', '%3', %4, %5, 1000)") .arg(cible).arg(s, id, ms(premier), ms(dernier)); }; QVERIFY(executer(chemin, { // Pas de 10:00. Veille = 12/10 16:00Z ; H−1 = 13/10 09:00Z. // A : vue de 06:00 la veille, REVUE le 13 à 07:00 (un retour, cas du §23.2). rev(pas10, "A", utc(2026, 10, 12, 6), utc(2026, 10, 13, 7)), // B : apparue 14:30 la veille, vue jusqu'à 17:00 — c'est ELLE qui avait cours à 16:00. rev(pas10, "B", utc(2026, 10, 12, 14, 30), utc(2026, 10, 12, 17)), // C : apparue 08:30 le 13, vue jusqu'à 11:00 — en vigueur à 09:00. rev(pas10, "C", utc(2026, 10, 13, 8, 30), utc(2026, 10, 13, 11)), // Un AUTRE site, apparu plus tard : ne doit jamais être désigné. rev(pas10, "Z", utc(2026, 10, 13, 8, 45), utc(2026, 10, 13, 11), "{autre}"), // Pas de 09:00. H−1 = 08:00Z : AUCUNE révision n'était vue à cet instant (B finit à // 07:00, C commence à 08:30) — la dernière vue avant, B, est celle qui avait cours. rev(pas9, "B9", utc(2026, 10, 12, 14), utc(2026, 10, 13, 7)), rev(pas9, "C9", utc(2026, 10, 13, 8, 30), utc(2026, 10, 13, 10)), })); ForecastLearningLog log(chemin, Paris); const QDateTime t0 = utc(2026, 10, 13, 9); const QList plans{ planObserve("{p}", 1, t0, {800, 900, 1000}, {{"kwp", 2.0}})}; log.observer(utc(2026, 10, 13, 9, 30), site, plans, 1, std::nullopt, std::nullopt, true); log.observer(utc(2026, 10, 13, 11, 5), site, plans, 1, std::nullopt, std::nullopt, true); QTRY_COMPARE(log.lotsTraites(), 1); const QList l = lignesApprentissage(chemin); QCOMPARE(l.count(), 2); QCOMPARE(l.at(0).value("targetAt").toLongLong(), pas9); QCOMPARE(l.at(0).value("contentIdHourAhead").toString(), QString("B9")); // Veille du pas de 09:00 = 12/10 16:00Z : seule B9 était apparue (14:00) et encore vue. QCOMPARE(l.at(0).value("contentIdDayAhead").toString(), QString("B9")); QCOMPARE(l.at(1).value("targetAt").toLongLong(), pas10); // `MAX(lastSeenAt)` — la règle de la courbe COURANTE — aurait rendu A ici. QCOMPARE(l.at(1).value("contentIdDayAhead").toString(), QString("B")); QCOMPARE(l.at(1).value("contentIdHourAhead").toString(), QString("C")); // Aucune révision pour un pas : NULL — jamais un identifiant deviné. QTemporaryDir vide; const QString cheminVide = vide.filePath("energy-forecast.sqlite"); ForecastLearningLog log2(cheminVide, Paris); log2.observer(utc(2026, 10, 13, 9, 30), site, plans, 1, std::nullopt, std::nullopt, true); log2.observer(utc(2026, 10, 13, 10, 5), site, plans, 1, std::nullopt, std::nullopt, true); QTRY_COMPARE(log2.lotsTraites(), 1); const QList l2 = lignesApprentissage(cheminVide); QCOMPARE(l2.count(), 1); QVERIFY(l2.first().value("contentIdDayAhead").isNull()); QVERIFY(l2.first().value("contentIdHourAhead").isNull()); // « J−1 18:00 locale » autour des changements d'heure — oracles calculés hors de Qt. QCOMPARE(ForecastLearningLog::instantVeille(utc(2026, 10, 12, 22, 30), Paris).toSecsSinceEpoch(), qint64(1791820800)); // 00:30 locale le 13 : la veille est le 12, pas le 11 QCOMPARE(ForecastLearningLog::instantVeille(utc(2026, 10, 25, 10), Paris).toSecsSinceEpoch(), qint64(1792857600)); // jour de 25 h : la veille est encore en heure d'été QCOMPARE(ForecastLearningLog::instantVeille(utc(2026, 10, 25, 23, 30), Paris).toSecsSinceEpoch(), qint64(1792947600)); // le 26 local : 18:00 du 25, déjà en heure d'hiver QCOMPARE(ForecastLearningLog::instantVeille(utc(2026, 3, 29, 10), Paris).toSecsSinceEpoch(), qint64(1774717200)); // jour de 23 h : la veille est encore en heure d'hiver } void TestLoadModel::testTheLearningLogSurvivesADamagedTable() { const QDateTime t0 = utc(2026, 10, 13, 10); const QList plans{ planObserve("{p}", 1, t0, {800, 900, 1000}, {{"kwp", 2.0}})}; auto pas = [&](ForecastLearningLog &log, int i) { const QDateTime d = t0.addSecs(3600 * i); log.observer(d.addSecs(60), "{s}", plans, 1, std::nullopt, std::nullopt, true); log.observer(d.addSecs(3600 + 150), "{s}", plans, 1, std::nullopt, std::nullopt, true); }; // 1. UNE TABLE DE FORME INATTENDUE — une version future, une écriture étrangère : REFAITE. { QTemporaryDir dir; const QString chemin = dir.filePath("energy-forecast.sqlite"); QVERIFY(executer(chemin, {"CREATE TABLE forecastLearning (x INTEGER, y TEXT)", "INSERT INTO forecastLearning VALUES (1, 'étranger')"})); ForecastLearningLog log(chemin, Paris); pas(log, 0); QTRY_COMPARE(log.lotsTraites(), 1); QVERIFY(log.isValid()); const QList l = lignesApprentissage(chemin); QCOMPARE(l.count(), 1); QVERIFY(l.first().contains("contentIdDayAhead")); // la forme attendue, refaite // 2. LA TABLE DISPARAÎT entre deux lots : recréée à l'écriture, lot REJOUÉ, rien perdu // de ce lot-là — et le service n'a rien vu. QVERIFY(executer(chemin, {"DROP TABLE forecastLearning"})); pas(log, 1); QTRY_COMPARE(log.lotsTraites(), 2); QCOMPARE(lignesApprentissage(chemin).count(), 1); QCOMPARE(log.lignesEcrites(), 2); // 3. RÉTENTION 400 jours — sur l'instant du CYCLE, pas sur l'horloge murale. const qint64 vieux = t0.addDays(-401).toSecsSinceEpoch(); const qint64 garde = t0.addDays(-399).toSecsSinceEpoch(); QString ins = QStringLiteral( "INSERT INTO forecastLearning (targetAt, stepS, siteId, planId, corrections, transportV, " "observedAt, sitePlans, sitePlansExpected, dayAheadAt, measuredMinutes, valid, writtenAt) " "VALUES (%1, 3600, '{s}', '{p}', '', 1, 0, 1, 1, 0, 0, 0, 0)"); QVERIFY(executer(chemin, {ins.arg(vieux), ins.arg(garde)})); pas(log, 2); QTRY_COMPARE(log.lotsTraites(), 3); QCOMPARE(lignesApprentissage(chemin, QStringLiteral("targetAt = %1").arg(vieux)).count(), 0); QCOMPARE(lignesApprentissage(chemin, QStringLiteral("targetAt = %1").arg(garde)).count(), 1); } // 4. UNE TABLE ABÎMÉE sur le disque : la vérification à l'ouverture la voit, elle est refaite. { QTemporaryDir dir; const QString chemin = dir.filePath("energy-forecast.sqlite"); QStringList remplir{"PRAGMA page_size = 4096", QString::fromLatin1( "CREATE TABLE forecastLearning (targetAt INTEGER NOT NULL, stepS INTEGER NOT NULL, " "siteId TEXT NOT NULL, planId TEXT NOT NULL, gti REAL, tempC REAL, pW REAL, " "corrections TEXT NOT NULL, transportV INTEGER NOT NULL, kwp REAL, performanceRatio REAL, " "tempCoeffPctPerK REAL, noctC REAL, acLimitW INTEGER, issuedAt INTEGER, model TEXT, " "observedAt INTEGER NOT NULL, sitePlans INTEGER NOT NULL, sitePlansExpected INTEGER NOT NULL, " "dayAheadAt INTEGER NOT NULL, contentIdDayAhead TEXT, contentIdHourAhead TEXT, " "measuredW REAL, measuredSource TEXT, measuredMinutes INTEGER NOT NULL, curtailed INTEGER, " "snow INTEGER, valid INTEGER NOT NULL, writtenAt INTEGER NOT NULL, " "PRIMARY KEY (targetAt, siteId, planId))")}; for (int i = 0; i < 400; ++i) remplir << QStringLiteral( "INSERT INTO forecastLearning (targetAt, stepS, siteId, planId, corrections, " "transportV, observedAt, sitePlans, sitePlansExpected, dayAheadAt, measuredMinutes, " "valid, writtenAt) VALUES (%1, 3600, '{s}', '{p%2}', '', 1, 0, 1, 1, 0, 0, 0, 0)") .arg(i).arg(i); QVERIFY(executer(chemin, remplir)); // On écrase une page de DONNÉES de la table — pas l'en-tête du fichier, ni le schéma. QFile f(chemin); QVERIFY(f.open(QIODevice::ReadWrite)); QVERIFY(f.size() >= 4 * 4096); f.seek(3 * 4096); f.write(QByteArray(4096, char(0x5a))); f.close(); ForecastLearningLog log(chemin, Paris); pas(log, 0); QTRY_COMPARE(log.lotsTraites(), 1); QVERIFY(log.isValid()); QCOMPARE(log.lignesEcrites(), 1); const QList l = lignesApprentissage(chemin); QCOMPARE(l.count(), 1); // l'ancien contenu est perdu, et dit QCOMPARE(l.first().value("planId").toString(), QString("{p}")); } // 5. UN FICHIER QUI N'EST PAS UNE BASE : l'enregistrement s'arrête, rien ne plante, le // cycle continue d'appeler sans effet. { QTemporaryDir dir; const QString chemin = dir.filePath("energy-forecast.sqlite"); QFile f(chemin); QVERIFY(f.open(QIODevice::WriteOnly)); f.write(QByteArray(8192, char(0x42))); f.close(); ForecastLearningLog log(chemin, Paris); pas(log, 0); QTRY_COMPARE(log.lotsTraites(), 1); QVERIFY(!log.isValid()); QCOMPARE(log.lignesEcrites(), 0); } } void TestLoadModel::testTheLearningLogNeverBlocksTheCycle() { // La base est VERROUILLÉE en écriture par une autre connexion pendant que le cycle remet un // lot. L'appel rend la main tout de suite ; la ligne arrive quand le verrou tombe. QTemporaryDir dir; const QString chemin = dir.filePath("energy-forecast.sqlite"); const QDateTime t0 = utc(2026, 10, 13, 10); const QList plans{ planObserve("{p}", 1, t0, {800, 900}, {{"kwp", 2.0}})}; ForecastLearningLog log(chemin, Paris); log.observer(t0.addSecs(60), "{s}", plans, 1, std::nullopt, std::nullopt, true); // Le fil a ouvert la base et créé la table : sinon le verrou ci-dessous gênerait l'ouverture, // pas l'écriture, et le test mesurerait autre chose. QTRY_VERIFY(executer(chemin, {"SELECT 1 FROM forecastLearning LIMIT 0"})); const QString nom = QStringLiteral("verrou"); { QSqlDatabase db = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), nom); db.setDatabaseName(chemin); QVERIFY(db.open()); QSqlQuery q(db); QVERIFY(q.exec(QStringLiteral("BEGIN EXCLUSIVE"))); QElapsedTimer chrono; chrono.start(); log.observer(t0.addSecs(3600 + 150), "{s}", plans, 1, std::nullopt, std::nullopt, true); const qint64 duree = chrono.elapsed(); QVERIFY2(duree < 50, qPrintable(QStringLiteral("observer() a bloqué %1 ms").arg(duree))); QTest::qWait(300); QCOMPARE(log.lignesEcrites(), 0); // le fil attend le verrou, pas nous QVERIFY(q.exec(QStringLiteral("COMMIT"))); db.close(); } QSqlDatabase::removeDatabase(nom); QTRY_COMPARE(log.lignesEcrites(), 1); } namespace { //! LA TABLE etm67, TELLE QUE SQLite L'A ENREGISTRÉE dans `sqlite_master` — la chaîne exacte de //! `Creation` à b7317f3 (1.15.2+etm67, déployée sur le banc). Elle sert à PROUVER que la base //! de référence ci-dessous est bien une base etm67, pas une imitation qui lui ressemble. const char *SchemaEtm67 = "CREATE TABLE forecastLearning ( targetAt INTEGER NOT NULL, stepS " "INTEGER NOT NULL, siteId TEXT NOT NULL, planId TEXT NOT NULL," " gti REAL, tempC REAL, pW REAL, corrections " " TEXT NOT NULL, transportV INTEGER NOT NULL, kwp REAL, " "performanceRatio REAL, tempCoeffPctPerK REAL, noctC REAL, acLimitW " " INTEGER, issuedAt INTEGER, model TEXT, observedAt INTEGER " "NOT NULL, sitePlans INTEGER NOT NULL, sitePlansExpected INTEGER NOT NULL, " "dayAheadAt INTEGER NOT NULL, contentIdDayAhead TEXT, contentIdHourAhead TEXT, " "measuredW REAL, measuredSource TEXT, measuredMinutes INTEGER NOT NULL, " "curtailed INTEGER, snow INTEGER, valid INTEGER NOT NULL," " writtenAt INTEGER NOT NULL, PRIMARY KEY (targetAt, siteId, planId))"; QStringList colonnesApprentissage(const QString &chemin) { QStringList sortie; const QString nom = QStringLiteral("colonnes-%1").arg(QUuid::createUuid().toString()); { QSqlDatabase db = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), nom); db.setDatabaseName(chemin); if (db.open()) { QSqlQuery q(db); if (q.exec(QStringLiteral("PRAGMA table_info(forecastLearning)"))) while (q.next()) sortie << q.value(1).toString(); db.close(); } } QSqlDatabase::removeDatabase(nom); return sortie; } QStringList tables(const QString &chemin) { QStringList sortie; const QString nom = QStringLiteral("tables-%1").arg(QUuid::createUuid().toString()); { QSqlDatabase db = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), nom); db.setDatabaseName(chemin); if (db.open()) { QSqlQuery q(db); if (q.exec(QStringLiteral("SELECT name, sql FROM sqlite_master WHERE type = 'table' " "ORDER BY name"))) while (q.next()) sortie << q.value(0).toString() + QLatin1Char('\t') + q.value(1).toString(); db.close(); } } QSqlDatabase::removeDatabase(nom); return sortie; } } void TestLoadModel::testTheLearningLogMigratesAnEtm67TableInPlace() { // UNE BASE ÉCRITE PAR LE CODE etm67 LUI-MÊME (b7317f3, test de simulation du câblage), pas // reconstituée à la main : c'est elle que le banc porte depuis 09:24 le 2026-10-13. QTemporaryDir dir; const QString chemin = dir.filePath("energy-forecast.sqlite"); QVERIFY(QFile::copy(QStringLiteral(":/data/energy-forecast-etm67.sqlite"), chemin)); QVERIFY(QFile::setPermissions(chemin, QFile::ReadOwner | QFile::WriteOwner)); const QStringList avantTables = tables(chemin); QVERIFY2(avantTables.contains(QStringLiteral("forecastLearning\t") + QString::fromLatin1(SchemaEtm67)), qPrintable(avantTables.join('\n'))); const QList avant = lignesApprentissage(chemin); QVERIFY2(avant.count() >= 2, "la base de référence doit porter des lignes etm67"); QCOMPARE(avant.first().count(), 29); { ForecastLearningLog log(chemin, Paris); // L'ouverture migre : on attend les colonnes par `PRAGMA table_info`, dont la forme ne // change pas. Un `SELECT *` préparé PENDANT l'ALTER fait planter le pilote Qt SQLite // (29 colonnes à la préparation, 32 à la lecture) — vu une fois ; aucun lecteur de cette // table n'existe hors des tests. QTRY_VERIFY(colonnesApprentissage(chemin).contains(QStringLiteral("gtiObservedAt"))); QVERIFY(log.isValid()); const QList apres = lignesApprentissage(chemin); QCOMPARE(apres.count(), avant.count()); // AUCUNE ligne perdue for (int i = 0; i < avant.count(); ++i) { QCOMPARE(apres.at(i).count(), 32); for (auto c = avant.at(i).constBegin(); c != avant.at(i).constEnd(); ++c) QVERIFY2(apres.at(i).value(c.key()) == c.value(), qPrintable(c.key())); for (const char *c : {"gtiObserved", "gtiObservedSource", "gtiObservedAt"}) QVERIFY2(apres.at(i).value(c).isNull(), c); // inconnu, JAMAIS 0 } // Migrée, pas refaite ni mise de côté : une seule table forecastLearning, aucune autre. int apprentissage = 0; for (const QString &t : tables(chemin)) apprentissage += t.startsWith(QLatin1String("forecastLearning")) ? 1 : 0; QCOMPARE(apprentissage, 1); // Et la table migrée s'ÉCRIT : un pas neuf, observation comprise. const QDateTime t0 = utc(2027, 1, 5, 10); QList plans{planObserve("{p}", 1, t0, {800, 900}, {{"kwp", 2.0}})}; log.observer(t0.addSecs(60), "{s}", plans, 1, std::nullopt, std::nullopt, true); plans[0].gtiObserved = 310.0; plans[0].gtiObservedAt = t0.addSecs(3600).toSecsSinceEpoch(); // Le pas est encore en tampon à ce cycle : l'observation entre par l'INSERT. log.observer(t0.addSecs(3600 + 150), "{s}", plans, 1, std::nullopt, std::nullopt, true); QTRY_COMPARE(log.lotsTraites(), 1); const QList neuve = lignesApprentissage(chemin, QStringLiteral("targetAt = %1").arg(t0.toSecsSinceEpoch())); QCOMPARE(neuve.count(), 1); QCOMPARE(neuve.first().value("gtiObserved").toDouble(), 310.0); } // Un second démarrage sur la table migrée : forme attendue, rien à faire, rien de perdu. { ForecastLearningLog relance(chemin, Paris); QTest::qWait(200); QVERIFY(relance.isValid()); QCOMPARE(lignesApprentissage(chemin).count(), avant.count() + 1); } } void TestLoadModel::testTheLearningLogAttachesTheObservedGtiToItsStep() { QTemporaryDir dir; const QString chemin = dir.filePath("energy-forecast.sqlite"); const QString site = QStringLiteral("{s}"); const QDateTime t0 = utc(2026, 10, 13, 9); // grille : 09, 10, 11, 12, 13 h QList plans{ planObserve("{p}", 2, t0, {300, 1200, 1500, 1400, 900}, {{"kwp", 2.0}})}; auto observe = [&](double gti, const QDateTime &fin) { plans[0].gtiObserved = gti; plans[0].gtiObservedAt = fin.toSecsSinceEpoch(); }; auto ligne = [&](const QDateTime &debut) { const QList l = lignesApprentissage(chemin, QStringLiteral("targetAt = %1").arg(debut.toSecsSinceEpoch())); return l.isEmpty() ? QVariantMap() : l.first(); }; auto cycle = [&](ForecastLearningLog &log, const QDateTime &n) { log.observer(n, site, plans, 1, 3000, std::nullopt, true); }; ForecastLearningLog log(chemin, Paris); // 1. observedGtiTime == 0 — le défaut de l'état : RIEN, même si la valeur vaut 0. observe(0.0, QDateTime::fromSecsSinceEpoch(0, QTimeZone::utc())); cycle(log, utc(2026, 10, 13, 10, 5)); // premier cycle : le pas de 09 h est perdu minutes(log, utc(2026, 10, 13, 10), utc(2026, 10, 13, 11), 1100); cycle(log, utc(2026, 10, 13, 11, 3)); // écrit le pas de 10 h QTRY_COMPARE(log.lotsTraites(), 1); QTest::qWait(50); QCOMPARE(log.complementsTraites(), 0); const QVariantMap avant = ligne(utc(2026, 10, 13, 10)); QVERIFY(!avant.isEmpty()); for (const char *c : {"gtiObserved", "gtiObservedSource", "gtiObservedAt"}) QVERIFY2(avant.value(c).isNull(), c); QCOMPARE(avant.value("valid").toInt(), 1); // 2. LE RETARD : la valeur de 11:00 — moyenne de (10:00, 11:00] — arrive à 12:30, la ligne // du pas de 10 h est écrite depuis 11:03. Elle la COMPLÈTE, elle et rien d'autre. minutes(log, utc(2026, 10, 13, 11), utc(2026, 10, 13, 12), 1100); observe(420.0, utc(2026, 10, 13, 11)); cycle(log, utc(2026, 10, 13, 12, 30)); // écrit aussi le pas de 11 h QTRY_COMPARE(log.lotsTraites(), 2); QTRY_VERIFY2(!ligne(utc(2026, 10, 13, 10)).value("gtiObserved").isNull(), "la valeur publiée pour 11:00 moyenne 10:00-11:00 : elle va au pas de 10 h"); QCOMPARE(log.complementsTraites(), 1); QCOMPARE(log.gtiObservesCompletes(), 1); const QVariantMap apres = ligne(utc(2026, 10, 13, 10)); QCOMPARE(apres.value("gtiObserved").toDouble(), 420.0); QCOMPARE(apres.value("gtiObservedSource").toString(), QString("openmeteo-satellite-state")); QCOMPARE(apres.value("gtiObservedAt").toLongLong(), utc(2026, 10, 13, 11).toSecsSinceEpoch()); for (auto c = avant.constBegin(); c != avant.constEnd(); ++c) { if (!c.key().startsWith(QLatin1String("gtiObserved"))) QVERIFY2(apres.value(c.key()) == c.value(), qPrintable(QStringLiteral("%1 réécrit par le complément").arg(c.key()))); } // Le pas de 11 h — celui que DÉSIGNERAIT l'instant publié sans le décalage — n'a rien reçu. const QVariantMap onze = ligne(utc(2026, 10, 13, 11)); QVERIFY(!onze.isEmpty()); QVERIFY2(onze.value("gtiObserved").isNull(), "la valeur de 11:00 moyenne 10:00-11:00, pas 11:00-12:00"); // 3. L'état est relu à chaque cycle : la MÊME publication n'est pas renvoyée. cycle(log, utc(2026, 10, 13, 12, 40)); QTest::qWait(100); QCOMPARE(log.complementsTraites(), 1); // 4. LA NUIT : 0 W/m² PUBLIÉ est une observation, pas une absence. observe(0.0, utc(2026, 10, 13, 12)); cycle(log, utc(2026, 10, 13, 12, 50)); QTRY_COMPARE(log.complementsTraites(), 2); QVERIFY(!ligne(utc(2026, 10, 13, 11)).value("gtiObserved").isNull()); QCOMPARE(ligne(utc(2026, 10, 13, 11)).value("gtiObserved").toDouble(), 0.0); // 5. Une observation dont le pas n'a PAS de ligne (09 h, écoulé avant le premier cycle) : // rien n'est créé. observe(150.0, utc(2026, 10, 13, 10)); cycle(log, utc(2026, 10, 13, 12, 55)); QTRY_COMPARE(log.complementsTraites(), 3); QCOMPARE(log.gtiObservesCompletes(), 2); QVERIFY(ligne(utc(2026, 10, 13, 9)).isEmpty()); // 6. Le pas est ENCORE en tampon (fin 13:00, grâce jusqu'à 13:02) : l'observation entre dans // la ligne avant qu'elle soit écrite — par l'INSERT, sans complément. minutes(log, utc(2026, 10, 13, 12), utc(2026, 10, 13, 13), 1100); observe(555.0, utc(2026, 10, 13, 13)); cycle(log, utc(2026, 10, 13, 13, 1)); cycle(log, utc(2026, 10, 13, 13, 3)); QTRY_COMPARE(log.lotsTraites(), 3); QCOMPARE(ligne(utc(2026, 10, 13, 12)).value("gtiObserved").toDouble(), 555.0); QCOMPARE(log.complementsTraites(), 3); // 7. UNE VALEUR ÉCRITE N'EST JAMAIS REMPLACÉE — un redémarrage relit l'état mis en cache, // revoit l'instant de 11:00 avec une autre valeur : la ligne garde la sienne. { ForecastLearningLog relance(chemin, Paris); observe(999.0, utc(2026, 10, 13, 11)); relance.observer(utc(2026, 10, 13, 13, 10), site, plans, 1, 3000, std::nullopt, true); QTRY_COMPARE(relance.complementsTraites(), 1); QCOMPARE(relance.gtiObservesCompletes(), 0); } QCOMPARE(ligne(utc(2026, 10, 13, 10)).value("gtiObserved").toDouble(), 420.0); QCOMPARE(ligne(utc(2026, 10, 13, 10)).value("valid").toInt(), 1); // valid n'en dépend pas } // ============================================================================================ // Brief 3 T4 — le tarif saisi par l'installateur // ============================================================================================ namespace { QVariantMap zoneTarif(const QString &label, const QVariant &prix, const QList> &plages) { QVariantList r; for (const auto &p : plages) r << QVariantMap{{"from", p.first}, {"to", p.second}}; return QVariantMap{{"label", label}, {"priceCtkWh", prix}, {"ranges", r}}; } QVariantMap saisieTarif(const QVariantList &zones) { return QVariantMap{{"zones", zones}}; } //! HP/HC « 22:00-06:00 » à 15, le reste à 25 — le cas d'école des oracles. QVariantMap hphc() { return saisieTarif({zoneTarif("HC", 15.0, {{"22:00", "06:00"}}), zoneTarif("HP", 25.0, {{"06:00", "22:00"}})}); } } void TestLoadModel::testTheTariffIsValidatedStrictly() { QString cause; QVariantMap ou; // 1. ACCEPTÉS — le tarif unique, les HP/HC, l'HC en DEUX fenêtres. TariffConfig t = TariffConfig::fromVariant( saisieTarif({zoneTarif("Base", 20.16, {{"00:00", "24:00"}})}), cause, ou); QVERIFY2(cause.isEmpty(), qPrintable(cause)); QCOMPARE(t.source(), QStringLiteral("fixed")); QCOMPARE(t.exportCtkWh(), 0.0); // revente non saisie → 0 (brief) QVariantMap deuxFenetres = saisieTarif({zoneTarif("HC", 15.0, {{"12:00", "14:00"}, {"22:00", "06:00"}}), zoneTarif("HP", 25.0, {{"06:00", "12:00"}, {"14:00", "22:00"}})}); deuxFenetres.insert("exportCtkWh", 4); // un ENTIER est un nombre t = TariffConfig::fromVariant(deuxFenetres, cause, ou); QVERIFY2(cause.isEmpty(), qPrintable(cause)); QCOMPARE(t.source(), QStringLiteral("tou")); QCOMPARE(t.exportCtkWh(), 4.0); // L'aller-retour : la forme publiée est celle qu'on relit, sans perte. const TariffConfig relu = TariffConfig::fromVariant(t.toVariant(), cause, ou); QVERIFY(cause.isEmpty()); QVERIFY(relu == t); QCOMPARE(relu.toVariant(), t.toVariant()); // Une plage qui se termine à « 00:00 » passe minuit et vaut « 24:00 ». t = TariffConfig::fromVariant(saisieTarif({zoneTarif("Soir", 30.0, {{"22:00", "00:00"}}), zoneTarif("Jour", 20.0, {{"00:00", "22:00"}})}), cause, ou); QVERIFY2(cause.isEmpty(), qPrintable(cause)); // 2. L'EFFACEMENT est accepté et VIDE — distinct d'un refus par sa cause vide. t = TariffConfig::fromVariant(saisieTarif({}), cause, ou); QVERIFY(cause.isEmpty()); QVERIFY(t.isEmpty()); QVERIFY(t.source().isEmpty()); QVERIFY(t.importSeries(utc(2026, 6, 8, 0), 3600, 24, QTimeZone("Europe/Paris")).isEmpty()); // 3. REFUSÉS — chacun avec SON code et l'endroit en cause. struct Cas { const char *quoi; QVariantMap saisie; const char *cause; QVariantMap ou; }; QVariantMap exportNegatif = hphc(); exportNegatif.insert("exportCtkWh", -1); QVariantMap exportTexte = hphc(); exportTexte.insert("exportCtkWh", "6"); QVariantList treize; for (int i = 0; i < 13; ++i) treize << zoneTarif(QString::number(i), 1.0, {{"00:00", "01:00"}}); const QList cas = { {"zones absent", QVariantMap{}, "MALFORMED", {{"field", "zones"}}}, {"zones non liste", QVariantMap{{"zones", "HC"}}, "MALFORMED", {{"field", "zones"}}}, {"trop de zones", saisieTarif(treize), "TOO_MANY_ZONES", {{"max", 12}}}, {"trou", saisieTarif({zoneTarif("HC", 15.0, {{"22:00", "06:00"}}), zoneTarif("HP", 25.0, {{"06:00", "21:00"}})}), "GAP", {{"at", "21:00"}}}, {"chevauchement", saisieTarif({zoneTarif("HC", 15.0, {{"22:00", "06:00"}}), zoneTarif("HP", 25.0, {{"05:00", "22:00"}})}), "OVERLAP", {{"at", "05:00"}, {"zones", QVariantList{0, 1}}}}, {"chevauchement dans une zone", saisieTarif({zoneTarif("Base", 15.0, {{"00:00", "24:00"}, {"10:00", "11:00"}})}), "OVERLAP", {{"at", "10:00"}, {"zones", QVariantList{0, 0}}}}, {"prix négatif", saisieTarif({zoneTarif("HC", 15.0, {{"22:00", "06:00"}}), zoneTarif("HP", -0.01, {{"06:00", "22:00"}})}), "PRICE", {{"zone", 1}}}, {"prix en texte", saisieTarif({zoneTarif("Base", "20.16", {{"00:00", "24:00"}})}), "PRICE", {{"zone", 0}}}, {"prix booléen", saisieTarif({zoneTarif("Base", true, {{"00:00", "24:00"}})}), "PRICE", {{"zone", 0}}}, {"prix absent", saisieTarif({QVariantMap{{"label", "Base"}, {"ranges", QVariantList{QVariantMap{{"from", "00:00"}, {"to", "24:00"}}}}}}), "PRICE", {{"zone", 0}}}, {"libellé vide", saisieTarif({zoneTarif(" ", 20.0, {{"00:00", "24:00"}})}), "LABEL", {{"zone", 0}}}, {"libellé en double", saisieTarif({zoneTarif("HC", 15.0, {{"22:00", "06:00"}}), zoneTarif("HC", 25.0, {{"06:00", "22:00"}})}), "LABEL", {{"zone", 1}}}, {"aucune plage", saisieTarif({zoneTarif("Base", 20.0, {})}), "NO_RANGE", {{"zone", 0}}}, {"heure sans zéro", saisieTarif({zoneTarif("Base", 20.0, {{"0:00", "24:00"}})}), "RANGE_FORMAT", {{"zone", 0}, {"range", 0}}}, {"secondes", saisieTarif({zoneTarif("Base", 20.0, {{"00:00:00", "24:00"}})}), "RANGE_FORMAT", {{"zone", 0}, {"range", 0}}}, {"24:00 en début", saisieTarif({zoneTarif("Base", 20.0, {{"24:00", "24:00"}})}), "RANGE_FORMAT", {{"zone", 0}, {"range", 0}}}, {"25:00", saisieTarif({zoneTarif("Base", 20.0, {{"00:00", "25:00"}})}), "RANGE_FORMAT", {{"zone", 0}, {"range", 0}}}, {"from == to", saisieTarif({zoneTarif("Base", 20.0, {{"06:00", "06:00"}})}), "RANGE_EMPTY", {{"zone", 0}, {"range", 0}}}, {"00:00 → 00:00", saisieTarif({zoneTarif("Base", 20.0, {{"00:00", "00:00"}})}), "RANGE_EMPTY", {{"zone", 0}, {"range", 0}}}, {"revente négative", exportNegatif, "EXPORT_PRICE", {}}, {"revente en texte", exportTexte, "EXPORT_PRICE", {}}, }; for (const Cas &c : cas) { t = TariffConfig::fromVariant(c.saisie, cause, ou); QVERIFY2(t.isEmpty(), c.quoi); QVERIFY2(cause == QLatin1String(c.cause), qPrintable(QStringLiteral("%1 : %2 attendu, %3 obtenu").arg(c.quoi, c.cause, cause))); QVERIFY2(ou == c.ou, qPrintable(QStringLiteral("%1 : où = %2").arg(c.quoi, QString::fromUtf8(QJsonDocument::fromVariant(ou).toJson(QJsonDocument::Compact))))); } } void TestLoadModel::testTheTariffFollowsLocalTimeAcrossDstDays() { // ORACLES CALCULÉS HORS DE Qt (Python zoneinfo, minute par minute — script dans le rapport // T4) : la grille va du minuit LOCAL au minuit local suivant. L'attendu ne se fabrique pas // avec les primitives du code testé. QString cause; QVariantMap ou; const TariffConfig t = TariffConfig::fromVariant(hphc(), cause, ou); QVERIFY(cause.isEmpty()); const QTimeZone paris("Europe/Paris"); QVERIFY(paris.isValid()); QList hc5hp16hc2, hc7hp16hc2, hc6hp16hc2; for (int i = 0; i < 5; ++i) hc5hp16hc2 << 15.0; for (int i = 0; i < 7; ++i) hc7hp16hc2 << 15.0; for (int i = 0; i < 6; ++i) hc6hp16hc2 << 15.0; for (QList *l : {&hc5hp16hc2, &hc7hp16hc2, &hc6hp16hc2}) { for (int i = 0; i < 16; ++i) *l << 25.0; *l << 15.0 << 15.0; } struct Jour { const char *quoi; qint64 minuitLocal; int pas; QList attendu; }; const QList jours = { {"2026-03-29, 23 h", 1774738800, 23, hc5hp16hc2}, {"2026-10-25, 25 h", 1792879200, 25, hc7hp16hc2}, {"2026-06-08, 24 h", 1780869600, 24, hc6hp16hc2}, }; for (const Jour &j : jours) { QCOMPARE(j.attendu.count(), j.pas); const QList s = t.importSeries(QDateTime::fromSecsSinceEpoch(j.minuitLocal, QTimeZone::utc()), 3600, j.pas, paris); QVERIFY2(s == j.attendu, j.quoi); } // zoneAt() — la même lecture, pour un instant : 05:59 locale est HC, 06:00 est HP. QCOMPARE(t.zoneAt(QDateTime(QDate(2026, 6, 8), QTime(5, 59), paris), paris), 0); QCOMPARE(t.zoneAt(QDateTime(QDate(2026, 6, 8), QTime(6, 0), paris), paris), 1); // Le MÊME instant lu dans un autre fuseau change de zone : c'est un réglage MURAL. const QDateTime instant = QDateTime(QDate(2026, 6, 8), QTime(4, 30), QTimeZone::utc()); // 06:30 Paris QCOMPARE(t.zoneAt(instant, paris), 1); QCOMPARE(t.zoneAt(instant, QTimeZone::utc()), 0); } void TestLoadModel::testAStraddlingSlotGetsTheTimeWeightedMean() { QString cause; QVariantMap ou; const QTimeZone paris("Europe/Paris"); // HC 22:30-06:30 à 15, HP à 25 : les créneaux 06:00 et 22:00 sont MI-HC, MI-HP → 20. const TariffConfig demi = TariffConfig::fromVariant( saisieTarif({zoneTarif("HC", 15.0, {{"22:30", "06:30"}}), zoneTarif("HP", 25.0, {{"06:30", "22:30"}})}), cause, ou); QVERIFY(cause.isEmpty()); QList attendu; for (int i = 0; i < 6; ++i) attendu << 15.0; attendu << 20.0; for (int i = 0; i < 15; ++i) attendu << 25.0; attendu << 20.0 << 15.0; QCOMPARE(demi.importSeries(QDateTime::fromSecsSinceEpoch(1780869600, QTimeZone::utc()), 3600, 24, paris), attendu); // À CHEVAL **ET** SUR LE CHANGEMENT D'HEURE — zone A 02:30-03:30 à 10, B à 30 (oracle hors Qt). // 25 h : 02:00-03:00 existe DEUX fois (mi-A chaque fois), 03:00-04:00 une fois (mi-A) → 20, 20, 20. // 23 h : 02:00-03:00 n'existe pas ; le créneau qui suit minuit+2 h est 03:00-04:00 → 20. const TariffConfig dst = TariffConfig::fromVariant( saisieTarif({zoneTarif("A", 10.0, {{"02:30", "03:30"}}), zoneTarif("B", 30.0, {{"03:30", "02:30"}})}), cause, ou); QVERIFY(cause.isEmpty()); QList automne{30.0, 30.0, 20.0, 20.0, 20.0}; while (automne.count() < 25) automne << 30.0; QList printemps{30.0, 30.0, 20.0}; while (printemps.count() < 23) printemps << 30.0; QCOMPARE(dst.importSeries(QDateTime::fromSecsSinceEpoch(1792879200, QTimeZone::utc()), 3600, 25, paris), automne); QCOMPARE(dst.importSeries(QDateTime::fromSecsSinceEpoch(1774738800, QTimeZone::utc()), 3600, 23, paris), printemps); // Un pas de 15 min tombe sur les plages : pas de mélange, la grille suit (brief : pas lu). const QList quarts = demi.importSeries(QDateTime(QDate(2026, 6, 8), QTime(6, 0), paris), 900, 4, paris); QCOMPARE(quarts, (QList{15.0, 15.0, 25.0, 25.0})); }