diff --git a/energyplugin/etm/scheduler/optimizerprotocol.cpp b/energyplugin/etm/scheduler/optimizerprotocol.cpp index c78a770..10ba3d7 100644 --- a/energyplugin/etm/scheduler/optimizerprotocol.cpp +++ b/energyplugin/etm/scheduler/optimizerprotocol.cpp @@ -15,10 +15,20 @@ namespace OptimizerProtocol { namespace { -//! Instant absolu, décalage compris (§5.2 « Instants ») ; invalide → \c null. +/*! + * Instant absolu, décalage compris (§5.2 « Instants ») ; invalide → \c null. + * + * Le décalage est celui de l'instant reçu, rendu EXPLICITE : Qt n'en écrit aucun pour une heure + * locale (\c Qt::LocalTime, celle de \c currentDateTime()), et une heure murale nue est refusée + * par tout optimiseur conforme (-32602, constat du 2026-10-21 sous Europe/Berlin). Une heure + * locale garde l'heure du site (« 13:42:00+02:00 »), une heure UTC reste en « Z ». Le décalage + * est lu sur l'INSTANT, pas sur la date : la nuit du passage à l'heure d'hiver, 02:30 sort + * « +02:00 » puis « +01:00 », deux instants distincts. + */ QVariant instant(const QDateTime &dt) { - return dt.isValid() ? QVariant(dt.toString(Qt::ISODate)) : QVariant(); + return dt.isValid() ? QVariant(dt.toOffsetFromUtc(dt.offsetFromUtc()).toString(Qt::ISODate)) + : QVariant(); } QVariantList liste(const QList &v) diff --git a/tests/auto/loadmodel/testloadmodel.h b/tests/auto/loadmodel/testloadmodel.h index 711f4dc..b74f4f6 100644 --- a/tests/auto/loadmodel/testloadmodel.h +++ b/tests/auto/loadmodel/testloadmodel.h @@ -148,6 +148,12 @@ private slots: void testGetPlanParamsMeterIsRaw(); //! Wh par pas → puissance moyenne ; une case sans point reste null. void testForecastWhPerStepBecomesMeanPower(); + //! Un contexte en HEURE LOCALE (celle de currentDateTime()) émet des instants AVEC décalage, + //! sous plusieurs fuseaux, été comme hiver (constat VM du 2026-10-21 : -32602 à chaque cycle). + void testGetPlanParamsInstantsCarryTheirOffset_data(); + void testGetPlanParamsInstantsCarryTheirOffset(); + //! Nuit du changement d'heure : 02:30 ambiguë (hiver) et inexistante (été) → instants exacts. + void testGetPlanParamsInstantsAcrossTheDstChange(); // ── Brief 4 T5 — catalogue des motifs (corps dans testdecisioncatalogue.cpp) ── //! Anti-dérive : knownCodes() == les constantes de l'en-tête, chacune avec un rendu connu. diff --git a/tests/auto/loadmodel/testoptimizerprotocol.cpp b/tests/auto/loadmodel/testoptimizerprotocol.cpp index 52ed4a8..c2e04ab 100644 --- a/tests/auto/loadmodel/testoptimizerprotocol.cpp +++ b/tests/auto/loadmodel/testoptimizerprotocol.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -164,6 +165,38 @@ void cles(const QVariant &v, QSet &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() @@ -410,3 +443,112 @@ void TestLoadModel::testForecastWhPerStepBecomesMeanPower() // 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; + } + } +}