// SPDX-License-Identifier: GPL-3.0-or-later // // Brief 4 T1 — sérialiseur GetPlan (OPTIMIZER_PROTOCOL 1.1 §5). // Logique pure, comme le reste de cette suite : aucun serveur, aucun arbitre. #include "testloadmodel.h" #include "etm/scheduler/optimizerprotocol.h" #include #include #include #include #include #include #include using namespace OptimizerProtocol; namespace { //! L'instant de l'exemple du §5.1 : 2026-10-12T13:42:00+02:00. QDateTime instantExemple() { return QDateTime(QDate(2026, 10, 12), QTime(13, 42), QTimeZone(7200)); } const QString kCompteur = QStringLiteral("{0d4c6a7e-1111-4c1a-9a01-000000000001}"); const QString kEcs = QStringLiteral("{0d4c6a7e-2222-4c1a-9a01-000000000002}"); const QString kBorne = QStringLiteral("{0d4c6a7e-3333-4c1a-9a01-000000000003}"); const QString kPac = QStringLiteral("{0d4c6a7e-4444-4c1a-9a01-000000000004}"); const QString kRouteur = QStringLiteral("{0d4c6a7e-5555-4c1a-9a01-000000000005}"); const QString kPlanSud = QStringLiteral("{0d4c6a7e-6666-4c1a-9a01-000000000006}"); //! Chauffe-eau sur routeur de relais : 2 400 W, plus petit palier 800 W, obligation de 4 kWh à 18 h. LoadContext chauffeEau(const QString &id = kEcs) { LoadContext lc; lc.id = id; lc.adapter = QStringLiteral("relay-router"); lc.label = QStringLiteral("Chauffe-eau"); lc.priority = 2; lc.declared.powerLevels = {0, 800, 1600, 2400}; // non émis (écart E9) lc.declared.minPowerW = 800; lc.declared.maxPowerW = 2400; lc.telemetry.currentPowerW = 1600; lc.telemetry.lockMinPowerW = 1600; lc.telemetry.lockMaxPowerW = -1; lc.needs.dailyDeadline = QStringLiteral("18:00"); lc.needs.minEnergyWhPerDay = 4000; lc.limits.minOnS = 300; lc.limits.minOffS = 300; return lc; } LoadContext borne() { LoadContext lc; lc.id = kBorne; lc.adapter = QStringLiteral("evcharger"); lc.label = QStringLiteral("Borne garage"); lc.priority = 1; lc.declared.minA = 6; lc.declared.maxA = 16; lc.declared.phases = 3; lc.declared.minPowerW = 4140; lc.declared.maxPowerW = 11040; lc.telemetry.currentPowerW = 4100; lc.telemetry.pluggedIn = true; lc.telemetry.charging = true; lc.telemetry.sessionMeasurable = true; lc.telemetry.sessionWh = 5200; lc.telemetry.lockMaxPowerW = -1; lc.needs.targetSocPercent = 80; lc.needs.deadline = QDateTime(QDate(2026, 10, 13), QTime(7, 0), QTimeZone(7200)); lc.limits.chargingEnabledLockS = 300; lc.limits.currentChangeLockS = 60; return lc; } LoadContext pac() { LoadContext lc; lc.id = kPac; lc.adapter = QStringLiteral("sg-ready"); lc.label = QStringLiteral("PAC"); lc.priority = 3; lc.declared.states = {1, 2, 3, 4}; lc.declared.estimatedPowerW = {{3, 1800}, {4, 2600}}; lc.declared.minPowerW = 1800; lc.telemetry.state = 2; lc.telemetry.minState = 2; lc.telemetry.maxState = 4; lc.limits.minStateHoldS = 900; return lc; } //! Grille sans courbe de \c instantanePrevision() : minuit local → minuit du surlendemain. QVariantMap instantaneSansCourbe(const QVariant &price) { QVariantList nuls; for (int i = 0; i < 48; ++i) nuls << QVariant(); return {{"start", "2026-10-11T22:00:00Z"}, {"resolutionS", 3600}, {"steps", 48}, {"baseload", QVariantMap{{"model", "none"}, {"p50W", nuls}}}, {"price", price}}; } //! Prix d'un tarif saisi à deux zones, tel que \c seriesConsoEtPrix() le pose sur la grille. QVariantMap prixTou() { QVariantList import, revente; for (int i = 0; i < 48; ++i) { const int heureLocale = i % 24; import << (heureLocale < 6 || heureLocale >= 22 ? 16.96 : 21.46); revente << 0.0; } return {{"currency", "EUR"}, {"importCtkWh", import}, {"exportCtkWh", revente}, {"includes", QStringList{"energy", "grid", "taxes"}}, {"source", "tou"}, {"knownUntil", "2026-10-13T22:00:00Z"}}; } /*! * Compare à un fichier de référence de \c data/optimizer. \c ETM_REGENERER_GOLDEN=1 le réécrit * (et le dit) : une régénération se relit en diff, elle ne se fait jamais en silence. */ void comparerAuGolden(const QString &nom, const QVariantMap ¶ms) { const QString chemin = QStringLiteral(ETM_GOLDEN_DIR "/%1.json").arg(nom); const QByteArray obtenu = QJsonDocument::fromVariant(params).toJson(QJsonDocument::Indented); if (qEnvironmentVariableIsSet("ETM_REGENERER_GOLDEN")) { QDir().mkpath(QStringLiteral(ETM_GOLDEN_DIR)); QFile f(chemin); QVERIFY(f.open(QIODevice::WriteOnly | QIODevice::Truncate)); f.write(obtenu); qWarning() << "golden RÉÉCRIT :" << chemin; return; } QFile f(chemin); QVERIFY2(f.open(QIODevice::ReadOnly), qUtf8Printable(QStringLiteral("golden absent : ") + chemin)); const QJsonDocument attendu = QJsonDocument::fromJson(f.readAll()); QVERIFY2(!attendu.isNull(), qUtf8Printable(chemin)); if (attendu != QJsonDocument::fromJson(obtenu)) { const QString trace = QDir::temp().filePath(nom + QStringLiteral(".obtenu.json")); QFile t(trace); if (t.open(QIODevice::WriteOnly | QIODevice::Truncate)) t.write(obtenu); QFAIL(qUtf8Printable(QStringLiteral("écart au golden %1 — obtenu écrit dans %2") .arg(chemin, trace))); } } //! Toutes les clés, à toute profondeur, d'une valeur JSON. void cles(const QVariant &v, QSet &out) { if (v.typeId() == QMetaType::QVariantMap) { const QVariantMap m = v.toMap(); for (auto it = m.constBegin(); it != m.constEnd(); ++it) { out.insert(it.key()); cles(it.value(), out); } } else if (v.typeId() == QMetaType::QVariantList) { for (const QVariant &e : v.toList()) cles(e, out); } } //! Le fuseau de la BOX, posé par le test et rendu en sortant (même garde que `simulation`). struct FuseauDeTest { QByteArray avant; bool avait; explicit FuseauDeTest(const QByteArray &tz) : avant(qgetenv("TZ")), avait(qEnvironmentVariableIsSet("TZ")) { qputenv("TZ", tz); } ~FuseauDeTest() { if (avait) qputenv("TZ", avant); else qunsetenv("TZ"); } }; //! Le motif `pattern` des instants du contrat OpenRPC (identique à `INSTANT` du validateur). const QRegularExpression kMotifInstant( QStringLiteral("^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}(:[0-9]{2}(\\.[0-9]+)?)?(Z|[+-][0-9]{2}:[0-9]{2})$")); //! Chaque instant émis (clés `timestamp`, `deadline`, `by`, `from`), avec son chemin. void instantsEmis(const QVariant &v, const QString &chemin, QList> &out) { if (v.typeId() == QMetaType::QVariantMap) { const QVariantMap m = v.toMap(); for (auto it = m.constBegin(); it != m.constEnd(); ++it) { const QString ici = chemin + QLatin1Char('.') + it.key(); if (QStringList{"timestamp", "deadline", "by", "from"}.contains(it.key()) && !it.value().isNull()) out << qMakePair(ici, it.value().toString()); else instantsEmis(it.value(), ici, out); } } else if (v.typeId() == QMetaType::QVariantList) { const QVariantList l = v.toList(); for (int i = 0; i < l.size(); ++i) instantsEmis(l.at(i), chemin + QStringLiteral("[%1]").arg(i), out); } } } // namespace void TestLoadModel::testGetPlanParamsGoldenSiteSansPv() { SurplusContext ctx; ctx.timestamp = instantExemple(); ctx.meter.importW = 850; ctx.meter.perPhaseA = {1.2, 2.3, 0.4}; LoadContext ecs = chauffeEau(); ecs.telemetry.currentPowerW = 0; ecs.telemetry.lockMinPowerW = 0; ctx.loads = {ecs}; ContexteFil fil; fil.meterSource = kCompteur; fil.phaseLimitA = {32, 32, 32}; fil.domaines.insert(kEcs, QStringLiteral("ecs")); fil.mesures.insert(kEcs, MesureFil{QStringLiteral("meter"), 0}); // Aucun site de prévision désigné, aucun candidat : l'instantané se tait sur la courbe, mais // la grille, la base (pas encore de profil) et le tarif saisi restent publiés. fil.prevision = instantaneSansCourbe(prixTou()); comparerAuGolden(QStringLiteral("site-sans-pv"), getPlanParams(ctx, fil)); } void TestLoadModel::testGetPlanParamsGoldenPvEcsBorneSgReady() { SurplusContext ctx; ctx.timestamp = instantExemple(); ctx.meter.exportW = 2310; ctx.meter.perPhaseA = {3.2, 4.1, 2.8}; ctx.battery.present = true; ctx.battery.socPercent = 62; ctx.battery.powerW = -800; LoadContext ecs = chauffeEau(); // Dérogation avec cible : elle devient un besoin `user-intent`. ecs.derogation = true; ecs.derogationEndsAt = QDateTime(QDate(2026, 10, 12), QTime(13, 0), QTimeZone::UTC); ecs.derogationTargetWh = 3000; ctx.loads = {borne(), ecs, pac()}; ContexteFil fil; fil.trigger = QStringLiteral("load-change"); fil.meterSource = kCompteur; fil.pvCurrentW = 3400; fil.phaseLimitA = {63, 63, 63}; fil.reserveSocPercent = 15; fil.domaines = {{kBorne, "ev"}, {kEcs, "ecs"}, {kPac, "heating"}}; fil.mesures = {{kBorne, MesureFil{"device", 4100}}, {kEcs, MesureFil{"meter", 1580}}, {kPac, MesureFil{}}}; // Courbe de 6 pas, un trou à 15:00Z : la case reste null, les séries restent alignées. QVariantList points; const QList> pts = {{"2026-10-12T12:00:00Z", 5200}, {"2026-10-12T13:00:00Z", 4800}, {"2026-10-12T14:00:00Z", 3900}, {"2026-10-12T16:00:00Z", 1500}, {"2026-10-12T17:00:00Z", 300}}; for (const auto &p : pts) points << QVariantMap{{"targetAt", p.first}, {"forecastWh", p.second}}; QVariantMap qualite{{"windowDays", 28}, {"minDays", 7}, {"resolutionS", 3600}, {"daysCovered", QVariantMap{{"workday", 14}, {"weekend", 6}}}, {"minutes", 28800}, {"commandedMinutes", 0}, {"negativeMinutes", 0}, {"incompleteMinutes", 12}, {"computedAt", "2026-10-12T03:00:00Z"}, {"loads", QVariantList{QVariantMap{{"loadId", kEcs}, {"source", "meter"}, {"minutesBySource", QVariantMap{{"meter", 28800}}}, {"constantMeasurement", false}}}}}; fil.prevision = QVariantMap{ {"start", "2026-10-12T12:00:00Z"}, {"resolutionS", 3600}, {"steps", 6}, {"baseload", QVariantMap{{"model", "profile"}, {"p50W", QVariantList{420.0, 380.0, 395.0, 450.0, 610.0, 720.0}}, {"quality", qualite}}}, {"price", QVariant()}, {"issuedAt", "2026-10-12T09:00:00Z"}, {"model", "meteofrance_seamless"}, {"contentChangedAt", "2026-10-12T10:07:00Z"}, {"forecastPoints", points}, {"corrections", QStringList{"temperature", "ac-limit"}}, {"perField", QVariantList{QVariantMap{{"id", kPlanSud}, {"corrections", QStringList{"temperature"}}}}}, {"conversion", "physics"}, {"acLimitW", 8000}}; comparerAuGolden(QStringLiteral("site-pv-ecs-borne-sgready"), getPlanParams(ctx, fil)); } void TestLoadModel::testGetPlanParamsGoldenPrevisionAbsente() { SurplusContext ctx; ctx.timestamp = instantExemple(); ctx.meter.exportW = 1200; ctx.meter.perPhaseA = {1.0, 1.0, 1.0}; ctx.loads = {chauffeEau()}; ContexteFil fil; fil.meterSource = kCompteur; // Des plans existent mais aucun site n'est désigné : l'instantané porte le motif et AUCUNE // courbe. Sur le fil, `pv` est null — pas une courbe de zéros — et le reste de la grille tient. QVariantMap instantane = instantaneSansCourbe(QVariant()); instantane.insert("fault", "MISSING_FORECAST_SITE"); instantane.insert("faultParams", QVariantMap{{"candidates", 2}}); fil.prevision = instantane; comparerAuGolden(QStringLiteral("prevision-absente"), getPlanParams(ctx, fil)); // Et sans instantané du tout : `forecast` est null (§5.2 « forecast peut être null »). fil.prevision.reset(); const QVariantMap params = getPlanParams(ctx, fil); QVERIFY(params.contains("forecast")); QVERIFY(params.value("forecast").isNull()); } void TestLoadModel::testGetPlanParamsGoldenChargeIndisponibleFigee() { SurplusContext ctx; ctx.timestamp = instantExemple(); ctx.meter.importW = 1350; ctx.meter.perPhaseA = {5.9, 0.3, 0.2}; // Routeur en défaut d'écriture : FIGÉ — indisponible, plancher = plafond = puissance crue // engagée, et toujours compté pour ce qu'il consomme (§5.3, champs ETM). LoadContext fige = chauffeEau(kRouteur); fige.label = QStringLiteral("Routeur garage"); fige.needs = LoadNeeds(); fige.telemetry.available = false; fige.telemetry.currentPowerW = 1200; fige.telemetry.lockMinPowerW = 1200; fige.telemetry.lockMaxPowerW = 1200; fige.telemetry.commandRefused = true; // code seul (écart E7) : non émis fige.telemetry.refusedSinceS = 340; // Borne configurée dont l'appareil a disparu (buildContext, 3g-2) : indisponible, sans // déclaration — ses zéros sont des inconnues et ne sont pas émis. LoadContext sansAppareil; sansAppareil.id = kBorne; sansAppareil.adapter = QStringLiteral("evcharger"); sansAppareil.label = QStringLiteral("Borne garage"); sansAppareil.priority = 1; sansAppareil.telemetry.available = false; sansAppareil.telemetry.lockMaxPowerW = -1; ctx.loads = {sansAppareil, fige}; ContexteFil fil; fil.meterSource = kCompteur; fil.mesures.insert(kRouteur, MesureFil{QStringLiteral("none"), 0}); fil.prevision = instantaneSansCourbe(QVariant()); comparerAuGolden(QStringLiteral("charge-indisponible-figee"), getPlanParams(ctx, fil)); } void TestLoadModel::testGetPlanParamsNeverInventsAValue() { // Un contexte tel que buildContext() le rend aujourd'hui : rien ne remplit la puissance // souscrite, la PV, la capacité batterie, ni le tarif. Leur 0 est une INCONNUE. SurplusContext ctx; ctx.timestamp = instantExemple(); ctx.battery.present = true; ctx.battery.socPercent = 40; ctx.loads = {chauffeEau(), borne(), pac()}; const QVariantMap p = getPlanParams(ctx, ContexteFil()); QCOMPARE(p.value("trigger").toString(), QStringLiteral("cycle")); const QVariantMap site = p.value("site").toMap(); for (const char *k : {"contractedPowerW", "gridCapW", "phaseLimitA"}) { QVERIFY2(site.contains(k) && site.value(k).isNull(), k); } QVERIFY(p.value("meter").toMap().value("source").isNull()); QVERIFY(p.value("pv").toMap().contains("currentW")); QVERIFY(p.value("pv").toMap().value("currentW").isNull()); const QVariantMap bat = p.value("battery").toMap(); for (const char *k : {"capacityWh", "maxChargeW", "maxDischargeW", "reserveSocPercent"}) { QVERIFY2(bat.contains(k) && bat.value(k).isNull(), k); } QVERIFY(p.contains("tariff") && p.value("tariff").isNull()); QVERIFY(p.contains("forecast") && p.value("forecast").isNull()); // Absents, pas null : group (non implémenté), learned (aucun profil reçu), comfort (aucun // porteur), champs réservés de l'annexe F, et `force`, qui n'existe pas sur le fil. QSet toutes; cles(p, toutes); for (const char *k : {"group", "learned", "comfort", "socTarget", "policies", "force", "powerLevels", "drawMargin", "derogation", "commandRefused", "nominalPowerW", "volumeL", "defaults", "vehicle", "minPowerWAlways"}) { QVERIFY2(!toutes.contains(QString::fromLatin1(k)), k); } // Sans mesure fournie, `measurement` est absent — jamais un {source: none} inventé. for (const QVariant &l : p.value("loads").toList()) QVERIFY(!l.toMap().value("telemetry").toMap().contains("measurement")); // Et une réserve renseignée par le CONTEXTE, si un jour il la porte, est reprise. ctx.battery.reserveSocPercent = 20; QCOMPARE(getPlanParams(ctx, ContexteFil()).value("battery").toMap() .value("reserveSocPercent").toInt(), 20); // Le tarif 1.0 est sérialisé dès qu'une source le remplit (écart E3 : aucune aujourd'hui). ctx.tariff.provider = QStringLiteral("tempo"); ctx.tariff.current = {QDateTime(), QStringLiteral("HP_BLEU"), 16.1}; ctx.tariff.next = {{QDateTime(QDate(2026, 10, 13), QTime(6, 0), QTimeZone(7200)), QStringLiteral("ROUGE"), 75.6}}; const QVariantMap t = getPlanParams(ctx, ContexteFil()).value("tariff").toMap(); QCOMPARE(t.value("provider").toString(), QStringLiteral("tempo")); QCOMPARE(t.value("current").toMap().value("priceCtkWh").toDouble(), 16.1); QCOMPARE(t.value("next").toList().first().toMap().value("from").toString(), QStringLiteral("2026-10-13T06:00:00+02:00")); } void TestLoadModel::testGetPlanParamsMeterIsRaw() { // Règle 8 : la mesure brute du compteur, quelles que soient les charges en marche. Une // charge qui tire 1 600 W ne « rend » rien à l'export. SurplusContext ctx; ctx.timestamp = instantExemple(); ctx.meter.exportW = 700; ctx.loads = {chauffeEau()}; const QVariantMap m = getPlanParams(ctx, ContexteFil()).value("meter").toMap(); QCOMPARE(m.value("exportW").toDouble(), 700.0); QCOMPARE(m.value("importW").toDouble(), 0.0); } void TestLoadModel::testForecastWhPerStepBecomesMeanPower() { // Pas de 15 min : 250 Wh sur le pas = 1 000 W moyens. Le consommateur ne suppose jamais le // pas (§5.2), le producteur non plus. const QVariantMap instantane{ {"start", "2026-10-12T12:00:00Z"}, {"resolutionS", 900}, {"steps", 4}, {"forecastPoints", QVariantList{ QVariantMap{{"targetAt", "2026-10-12T12:00:00Z"}, {"forecastWh", 250.0}}, QVariantMap{{"targetAt", "2026-10-12T12:45:00Z"}, {"forecastWh", 100.0}}, // Hors grille ou mal alignés : ignorés, jamais posés dans la case voisine. QVariantMap{{"targetAt", "2026-10-12T12:20:00Z"}, {"forecastWh", 999.0}}, QVariantMap{{"targetAt", "2026-10-12T13:00:00Z"}, {"forecastWh", 999.0}}}}, {"corrections", QStringList()}, {"conversion", "physics"}, {"perField", QVariantList()}, {"baseload", QVariantMap{{"model", "none"}, {"p50W", QVariantList{QVariant(), QVariant(), QVariant(), QVariant()}}}}, {"price", QVariant()}}; const QVariantMap f = forecastDepuisInstantane(instantane); const QVariantList p50 = f.value("pv").toMap().value("p50W").toList(); QCOMPARE(p50.size(), 4); QCOMPARE(p50.at(0).toDouble(), 1000.0); QVERIFY(p50.at(1).isNull()); QVERIFY(p50.at(2).isNull()); QCOMPARE(p50.at(3).toDouble(), 400.0); // Liste vide = conversion brute, une affirmation : elle reste une liste, pas une absence. QVERIFY(f.value("pv").toMap().contains("corrections")); QVERIFY(f.value("pv").toMap().value("corrections").toList().isEmpty()); // Inconnus du producteur : null, pas absents (§5.2). QVERIFY(f.value("pv").toMap().value("issuedAt").isNull()); QVERIFY(f.value("pv").toMap().value("weatherModel").isNull()); QVERIFY(f.value("pv").toMap().value("contentChangedAt").isNull()); QVERIFY(!f.value("pv").toMap().contains("acLimitW")); QVERIFY(f.value("weather").isNull()); // Le texte de faute de l'instantané ne passe jamais sur le fil. QVERIFY(!f.contains("fault")); } void TestLoadModel::testGetPlanParamsInstantsCarryTheirOffset_data() { QTest::addColumn("fuseau"); QTest::addColumn("jour"); QTest::addColumn("attendu"); QTest::newRow("Europe/Berlin, été") << QByteArray("Europe/Berlin") << QDate(2026, 7, 15) << QStringLiteral("2026-07-15T13:42:00+02:00"); QTest::newRow("Europe/Berlin, hiver") << QByteArray("Europe/Berlin") << QDate(2026, 1, 15) << QStringLiteral("2026-01-15T13:42:00+01:00"); QTest::newRow("America/New_York, hiver") << QByteArray("America/New_York") << QDate(2026, 1, 15) << QStringLiteral("2026-01-15T13:42:00-05:00"); QTest::newRow("Asia/Kolkata, demi-heure") << QByteArray("Asia/Kolkata") << QDate(2026, 7, 15) << QStringLiteral("2026-07-15T13:42:00+05:30"); QTest::newRow("UTC") << QByteArray("UTC") << QDate(2026, 7, 15) << QStringLiteral("2026-07-15T13:42:00Z"); } void TestLoadModel::testGetPlanParamsInstantsCarryTheirOffset() { // Constat VM du 2026-10-21 (fuseau Europe/Berlin) : l'optimiseur conforme refusait CHAQUE // GetPlan, `params.timestamp` sans décalage. En production `now` vient de // QDateTime::currentDateTime(), donc en Qt::LocalTime — pour laquelle Qt n'écrit aucun // décalage. Les goldens, construits en UTC ou à décalage fixe, ne pouvaient pas le voir. QFETCH(QByteArray, fuseau); QFETCH(QDate, jour); QFETCH(QString, attendu); FuseauDeTest garde(fuseau); const QDateTime now(jour, QTime(13, 42)); QCOMPARE(now.timeSpec(), Qt::LocalTime); // Tous les instants du contexte en heure locale : échéance quotidienne résolue sur `now` // (energyTargets installer), fin de dérogation (user-intent), échéance VE, tarif suivant. SurplusContext ctx; ctx.timestamp = now; LoadContext ecs = chauffeEau(); ecs.derogation = true; ecs.derogationEndsAt = now.addSecs(3600); ecs.derogationTargetWh = 3000; LoadContext ev = borne(); ev.needs.deadline = QDateTime(jour.addDays(1), QTime(7, 0)); ctx.loads = {ecs, ev, pac()}; ctx.tariff.provider = QStringLiteral("tempo"); ctx.tariff.current = {QDateTime(), QStringLiteral("HP_BLEU"), 16.1}; ctx.tariff.next = {{QDateTime(jour.addDays(1), QTime(6, 0)), QStringLiteral("ROUGE"), 75.6}}; const QVariantMap p = getPlanParams(ctx, ContexteFil()); QCOMPARE(p.value("timestamp").toString(), attendu); QList> instants; instantsEmis(p, QStringLiteral("params"), instants); QCOMPARE(instants.size(), 5); // timestamp, deadline, by ×2, tariff.next[0].from for (const auto &i : instants) { QVERIFY2(kMotifInstant.match(i.second).hasMatch(), qUtf8Printable(i.first + " = " + i.second)); // Relu, l'instant ne dépend plus du fuseau du lecteur : c'est un instant absolu. QVERIFY2(QDateTime::fromString(i.second, Qt::ISODate).timeSpec() != Qt::LocalTime, qUtf8Printable(i.first + " = " + i.second)); } QCOMPARE(QDateTime::fromString(p.value("timestamp").toString(), Qt::ISODate).toSecsSinceEpoch(), now.toSecsSinceEpoch()); } void TestLoadModel::testGetPlanParamsInstantsAcrossTheDstChange() { FuseauDeTest garde("Europe/Berlin"); auto emis = [](const QDateTime &t) { SurplusContext ctx; ctx.timestamp = t; return getPlanParams(ctx, ContexteFil()).value("timestamp").toString(); }; // HEURE D'HIVER, 2026-10-25 : à 03:00 CEST on revient à 02:00 CET. 02:30 murale a lieu DEUX // fois ; sans décalage, deux cycles distants d'une heure émettaient la même chaîne. const QDateTime premiere = QDateTime(QDate(2026, 10, 25), QTime(0, 30), QTimeZone::UTC).toLocalTime(); const QDateTime seconde = QDateTime(QDate(2026, 10, 25), QTime(1, 30), QTimeZone::UTC).toLocalTime(); QCOMPARE(premiere.time(), QTime(2, 30)); QCOMPARE(seconde.time(), QTime(2, 30)); QCOMPARE(emis(premiere), QStringLiteral("2026-10-25T02:30:00+02:00")); QCOMPARE(emis(seconde), QStringLiteral("2026-10-25T02:30:00+01:00")); // HEURE D'ÉTÉ, 2026-03-29 : à 02:00 CET on passe à 03:00 CEST, 02:30 murale n'existe pas. // Une horloge système ne la rend jamais ; un instant construit dessus est résolu par Qt, et // l'émis désigne CET instant-là, sous une heure qui existe. const QDateTime inexistante(QDate(2026, 3, 29), QTime(2, 30)); QVERIFY(inexistante.isValid()); const QString e = emis(inexistante); QVERIFY2(kMotifInstant.match(e).hasMatch(), qUtf8Printable(e)); QCOMPARE(QDateTime::fromString(e, Qt::ISODate).toSecsSinceEpoch(), inexistante.toSecsSinceEpoch()); QCOMPARE(emis(QDateTime(QDate(2026, 3, 29), QTime(1, 30), QTimeZone::UTC).toLocalTime()), QStringLiteral("2026-03-29T03:30:00+02:00")); // Un cycle par minute À TRAVERS chacune des deux nuits : chaînes toutes distinctes, conformes, // et relues dans l'ordre strict des instants — aucune ambiguïté, aucun trou. for (const QDate &nuit : {QDate(2026, 10, 25), QDate(2026, 3, 29)}) { const QDateTime debut(nuit, QTime(0, 0), QTimeZone::UTC); QSet vues; qint64 precedent = -1; for (int m = 0; m <= 180; ++m) { const QDateTime t = debut.addSecs(60 * m).toLocalTime(); const QString s = emis(t); QVERIFY2(kMotifInstant.match(s).hasMatch(), qUtf8Printable(s)); QVERIFY2(!vues.contains(s), qUtf8Printable(QStringLiteral("instant émis deux fois : ") + s)); vues.insert(s); const qint64 relu = QDateTime::fromString(s, Qt::ISODate).toSecsSinceEpoch(); QCOMPARE(relu, t.toSecsSinceEpoch()); QVERIFY(relu > precedent); precedent = relu; } } }