Fusion fix/instants-decalage : tout instant émis porte son décalage
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HE6X3TcKdiuCucBHA2XX4n
This commit is contained in:
commit
3a4398f0b1
@ -15,10 +15,20 @@ namespace OptimizerProtocol {
|
|||||||
|
|
||||||
namespace {
|
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)
|
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<double> &v)
|
QVariantList liste(const QList<double> &v)
|
||||||
|
|||||||
@ -15,6 +15,7 @@ adaptateurs, sur les mocks de la suite `simulation` — et vérifie, une fonctio
|
|||||||
| `testARefusedHandshakeIsClosedByTheOptimizer` | protocole 1.2, §3 : handshake d'une autre majeure → `-32602`, puis l'optimiseur **ferme** ; si un token est attendu, token faux → `-32001`, fermé pareil |
|
| `testARefusedHandshakeIsClosedByTheOptimizer` | protocole 1.2, §3 : handshake d'une autre majeure → `-32602`, puis l'optimiseur **ferme** ; si un token est attendu, token faux → `-32001`, fermé pareil |
|
||||||
| `testABatchIsRefused` | protocole 1.2, §2 : un lot `[…]` → **une** erreur `-32600` à `id: null`, rien du lot traité, connexion gardée |
|
| `testABatchIsRefused` | protocole 1.2, §2 : un lot `[…]` → **une** erreur `-32600` à `id: null`, rien du lot traité, connexion gardée |
|
||||||
| `testALineOverTheLimitIsRefused` | protocole 1.2, §2 : une ligne de `maxLineBytes` + 1 octets → `-32600` à `id: null`, connexion gardée ; une ligne d'exactement `maxLineBytes` est lue |
|
| `testALineOverTheLimitIsRefused` | protocole 1.2, §2 : une ligne de `maxLineBytes` + 1 octets → `-32600` à `id: null`, connexion gardée ; une ligne d'exactement `maxLineBytes` est lue |
|
||||||
|
| `testTheSessionMatchesTheContractOffUtc` | la fonction 3, le moteur sous un fuseau **non UTC** (`Europe/Berlin`) et cadencé en heure locale comme en production, à travers le passage à l'heure d'hiver (02:58 CEST → 02:01 CET) : aucun échec de requête, session conforme au contrat embarqué (tout instant porte son décalage, §5.2) |
|
||||||
|
|
||||||
Ces deux dernières lignes valent pour le mode `plan` (défaut). Voir **Optimiseur qui s'abstient**.
|
Ces deux dernières lignes valent pour le mode `plan` (défaut). Voir **Optimiseur qui s'abstient**.
|
||||||
|
|
||||||
@ -23,7 +24,7 @@ que son client n'envoie jamais, et lisent la réponse brute (un lot renvoyé se
|
|||||||
utilisent des requêtes dont la réponse est normée sans contexte — `UpdateLearned` avec `id` →
|
utilisent des requêtes dont la réponse est normée sans contexte — `UpdateLearned` avec `id` →
|
||||||
`-32601` (§8) — pour constater qu'un message a été, ou n'a pas été, traité.
|
`-32601` (§8) — pour constater qu'un message a été, ou n'a pas été, traité.
|
||||||
|
|
||||||
Les fonctions 2 à 5 passent par un relais transparent (une ligne reçue = une ligne renvoyée) qui
|
Les fonctions 2 à 5 et 10 passent par un relais transparent (une ligne reçue = une ligne renvoyée) qui
|
||||||
capture la session et mesure les délais ; la 5 est la seule où il réécrit une réponse. Banc : deux
|
capture la session et mesure les délais ; la 5 est la seule où il réécrit une réponse. Banc : deux
|
||||||
charges `etmvariableload` (0–2 000 W ; plancher 800, 3 000 W) et une PAC SG-Ready (P3 1 500 W,
|
charges `etmvariableload` (0–2 000 W ; plancher 800, 3 000 W) et une PAC SG-Ready (P3 1 500 W,
|
||||||
P4 3 000 W) ; surplus de −1 000 à 9 000 W.
|
P4 3 000 W) ; surplus de −1 000 à 9 000 W.
|
||||||
@ -40,7 +41,8 @@ export NYMEA_PLUGINS_PATH=<build>/tests/mocks/plugins/energymocks:/usr/lib/x86_6
|
|||||||
export ETM_POWERSYNC_OPTIMIZER_CMD='exec /chemin/de/mon-optimiseur --listen {endpoint}'
|
export ETM_POWERSYNC_OPTIMIZER_CMD='exec /chemin/de/mon-optimiseur --listen {endpoint}'
|
||||||
for fn in testHandshakeIsAccepted testGetPlanIsAnsweredInTime testTheSessionMatchesTheContract \
|
for fn in testHandshakeIsAccepted testGetPlanIsAnsweredInTime testTheSessionMatchesTheContract \
|
||||||
testEveryPlanIsAccepted testTheSafetyLoopStaysOnTop testGetPlanBeforeTheHandshakeIsRefused \
|
testEveryPlanIsAccepted testTheSafetyLoopStaysOnTop testGetPlanBeforeTheHandshakeIsRefused \
|
||||||
testARefusedHandshakeIsClosedByTheOptimizer testABatchIsRefused testALineOverTheLimitIsRefused; do
|
testARefusedHandshakeIsClosedByTheOptimizer testABatchIsRefused testALineOverTheLimitIsRefused \
|
||||||
|
testTheSessionMatchesTheContractOffUtc; do
|
||||||
systemd-run --user --scope -q -p MemoryMax=2G \
|
systemd-run --user --scope -q -p MemoryMax=2G \
|
||||||
bwrap --dev-bind / / --unshare-net --tmpfs /tmp --bind "$PWD/../../.." "$PWD/../../.." \
|
bwrap --dev-bind / / --unshare-net --tmpfs /tmp --bind "$PWD/../../.." "$PWD/../../.." \
|
||||||
--bind <sources> <sources> --chdir "$PWD" ./target_wrapper.sh ./etm-powersync-optimizer-conformance "$fn"
|
--bind <sources> <sources> --chdir "$PWD" ./target_wrapper.sh ./etm-powersync-optimizer-conformance "$fn"
|
||||||
|
|||||||
@ -20,7 +20,9 @@
|
|||||||
// 6 à 9 (protocole 1.2). Ce que le moteur n'envoie JAMAIS, envoyé par une connexion de SONDE
|
// 6 à 9 (protocole 1.2). Ce que le moteur n'envoie JAMAIS, envoyé par une connexion de SONDE
|
||||||
// (sans le moteur) : GetPlan avant le handshake (-32600, connexion gardée), handshake d'une
|
// (sans le moteur) : GetPlan avant le handshake (-32600, connexion gardée), handshake d'une
|
||||||
// autre majeure (-32602 puis fermeture par l'optimiseur), lot (-32600 à id null, rien du lot
|
// autre majeure (-32602 puis fermeture par l'optimiseur), lot (-32600 à id null, rien du lot
|
||||||
// traité), ligne de plus de 1 Mio (-32600 à id null, connexion gardée).
|
// traité), ligne de plus de 1 Mio (-32600 à id null, connexion gardée) ;
|
||||||
|
// 10. le point 3, le moteur réglé sur un fuseau NON UTC (Europe/Berlin) et cadencé en heure
|
||||||
|
// locale comme en production, à travers le passage à l'heure d'hiver.
|
||||||
//
|
//
|
||||||
// Mode attendu ($ETM_POWERSYNC_CONFORMANCE_EXPECT, défaut `plan`) : `abstain` juge un optimiseur
|
// Mode attendu ($ETM_POWERSYNC_CONFORMANCE_EXPECT, défaut `plan`) : `abstain` juge un optimiseur
|
||||||
// qui s'abstient toujours (§7, réponse permise) — le point 4 exige alors une abstention à chaque
|
// qui s'abstient toujours (§7, réponse permise) — le point 4 exige alors une abstention à chaque
|
||||||
@ -387,6 +389,15 @@ QString verifierErreur(const QByteArray &ligne, const QJsonValue &id, int code)
|
|||||||
return QString();
|
return QString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//! 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"); }
|
||||||
|
};
|
||||||
|
|
||||||
QString cle(const QVariantMap &v)
|
QString cle(const QVariantMap &v)
|
||||||
{
|
{
|
||||||
return QString::fromUtf8(QJsonDocument::fromVariant(v).toJson(QJsonDocument::Compact));
|
return QString::fromUtf8(QJsonDocument::fromVariant(v).toJson(QJsonDocument::Compact));
|
||||||
@ -408,6 +419,7 @@ private slots:
|
|||||||
void testARefusedHandshakeIsClosedByTheOptimizer();
|
void testARefusedHandshakeIsClosedByTheOptimizer();
|
||||||
void testABatchIsRefused();
|
void testABatchIsRefused();
|
||||||
void testALineOverTheLimitIsRefused();
|
void testALineOverTheLimitIsRefused();
|
||||||
|
void testTheSessionMatchesTheContractOffUtc();
|
||||||
|
|
||||||
private:
|
private:
|
||||||
void monter();
|
void monter();
|
||||||
@ -424,6 +436,8 @@ private:
|
|||||||
//! Lance l'optimiseur et y branche le moteur (directement, ou par le relais).
|
//! Lance l'optimiseur et y branche le moteur (directement, ou par le relais).
|
||||||
void brancher(bool parLeRelais);
|
void brancher(bool parLeRelais);
|
||||||
void cycle(const QDateTime &t);
|
void cycle(const QDateTime &t);
|
||||||
|
//! La session capturée par le relais, jugée par le validateur publié (point 3 et point 10).
|
||||||
|
void jugerLaSession();
|
||||||
//! Une série de cycles qui parcourt le banc : import, demi-charge, PAC en 3, puis en 4.
|
//! Une série de cycles qui parcourt le banc : import, demi-charge, PAC en 3, puis en 4.
|
||||||
void parcours(const QDateTime &t0);
|
void parcours(const QDateTime &t0);
|
||||||
QVariantMap etatApplique() const;
|
QVariantMap etatApplique() const;
|
||||||
@ -707,6 +721,11 @@ void TestOptimizerConformance::testTheSessionMatchesTheContract()
|
|||||||
if (QTest::currentTestFailed())
|
if (QTest::currentTestFailed())
|
||||||
return;
|
return;
|
||||||
parcours(utcDateTime(QDate(2026, 6, 8), QTime(9, 0, 0)));
|
parcours(utcDateTime(QDate(2026, 6, 8), QTime(9, 0, 0)));
|
||||||
|
jugerLaSession();
|
||||||
|
}
|
||||||
|
|
||||||
|
void TestOptimizerConformance::jugerLaSession()
|
||||||
|
{
|
||||||
QTemporaryDir dir;
|
QTemporaryDir dir;
|
||||||
QVERIFY(dir.isValid());
|
QVERIFY(dir.isValid());
|
||||||
const Contrat::Verdict v = Contrat::juger(m_relais->session,
|
const Contrat::Verdict v = Contrat::juger(m_relais->session,
|
||||||
@ -935,5 +954,34 @@ void TestOptimizerConformance::testALineOverTheLimitIsRefused()
|
|||||||
QVERIFY2(e.isEmpty(), qUtf8Printable(e));
|
QVERIFY2(e.isEmpty(), qUtf8Printable(e));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 10 --------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
void TestOptimizerConformance::testTheSessionMatchesTheContractOffUtc()
|
||||||
|
{
|
||||||
|
// Constat VM du 2026-10-21 : sous Europe/Berlin, l'optimiseur conforme refusait CHAQUE GetPlan
|
||||||
|
// (-32602, `params.timestamp` sans décalage), et les points 2 à 5 ne pouvaient pas le voir — ils
|
||||||
|
// cadencent le moteur en UTC. Ici, le fuseau de la box n'est pas UTC et les cycles sont en
|
||||||
|
// Qt::LocalTime, comme `QDateTime::currentDateTime()` en production. Le parcours démarre à
|
||||||
|
// 02:58 CEST le 2026-10-25 et traverse le retour à 02:00 CET : les deux décalages, et l'heure
|
||||||
|
// ambiguë, passent sur le fil.
|
||||||
|
FuseauDeTest fuseau("Europe/Berlin");
|
||||||
|
monter();
|
||||||
|
if (QTest::currentTestFailed())
|
||||||
|
return;
|
||||||
|
brancher(true);
|
||||||
|
if (QTest::currentTestFailed())
|
||||||
|
return;
|
||||||
|
const QDateTime t0 = utcDateTime(QDate(2026, 10, 25), QTime(0, 58, 0)).toLocalTime();
|
||||||
|
QCOMPARE(t0.timeSpec(), Qt::LocalTime);
|
||||||
|
QCOMPARE(t0.offsetFromUtc(), 7200);
|
||||||
|
QCOMPARE(t0.addSecs(30 * 7).offsetFromUtc(), 3600);
|
||||||
|
parcours(t0);
|
||||||
|
if (QTest::currentTestFailed())
|
||||||
|
return;
|
||||||
|
// Le jugement de l'optimiseur d'abord : un refus du contexte se lit ici, avec son code.
|
||||||
|
QVERIFY2(m_echecs.isEmpty(), qUtf8Printable("requêtes en échec : " + m_echecs.join(", ")));
|
||||||
|
jugerLaSession();
|
||||||
|
}
|
||||||
|
|
||||||
QTEST_MAIN(TestOptimizerConformance)
|
QTEST_MAIN(TestOptimizerConformance)
|
||||||
#include "testconformance.moc"
|
#include "testconformance.moc"
|
||||||
|
|||||||
@ -150,6 +150,12 @@ private slots:
|
|||||||
void testGetPlanParamsMeterIsRaw();
|
void testGetPlanParamsMeterIsRaw();
|
||||||
//! Wh par pas → puissance moyenne ; une case sans point reste null.
|
//! Wh par pas → puissance moyenne ; une case sans point reste null.
|
||||||
void testForecastWhPerStepBecomesMeanPower();
|
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) ──
|
// ── 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.
|
//! Anti-dérive : knownCodes() == les constantes de l'en-tête, chacune avec un rendu connu.
|
||||||
|
|||||||
@ -10,6 +10,7 @@
|
|||||||
#include <QDir>
|
#include <QDir>
|
||||||
#include <QFile>
|
#include <QFile>
|
||||||
#include <QJsonDocument>
|
#include <QJsonDocument>
|
||||||
|
#include <QRegularExpression>
|
||||||
#include <QSet>
|
#include <QSet>
|
||||||
#include <QTest>
|
#include <QTest>
|
||||||
#include <QTimeZone>
|
#include <QTimeZone>
|
||||||
@ -164,6 +165,38 @@ void cles(const QVariant &v, QSet<QString> &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<QPair<QString, QString>> &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
|
} // namespace
|
||||||
|
|
||||||
void TestLoadModel::testGetPlanParamsGoldenSiteSansPv()
|
void TestLoadModel::testGetPlanParamsGoldenSiteSansPv()
|
||||||
@ -410,3 +443,112 @@ void TestLoadModel::testForecastWhPerStepBecomesMeanPower()
|
|||||||
// Le texte de faute de l'instantané ne passe jamais sur le fil.
|
// Le texte de faute de l'instantané ne passe jamais sur le fil.
|
||||||
QVERIFY(!f.contains("fault"));
|
QVERIFY(!f.contains("fault"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void TestLoadModel::testGetPlanParamsInstantsCarryTheirOffset_data()
|
||||||
|
{
|
||||||
|
QTest::addColumn<QByteArray>("fuseau");
|
||||||
|
QTest::addColumn<QDate>("jour");
|
||||||
|
QTest::addColumn<QString>("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<QPair<QString, QString>> 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<QString> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user