test(conformance) : la session jugée sous un fuseau non UTC

testTheSessionMatchesTheContractOffUtc : le moteur sous Europe/Berlin,
cadencé en heure locale comme en production, du 2026-10-25 02:58 CEST au
retour à 02:00 CET. Aucun échec de requête, et la session passe
tools/etm-powersync-schema-check --semantic contre le contrat embarqué.

Contre-épreuve, correctif retiré : 8 rpc-error (le constat VM), et le
validateur seul refuse chaque params.timestamp sur son motif `pattern`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-10-21 22:10:15 +02:00
parent 9d8b60fdd4
commit f29d83e2f9
2 changed files with 53 additions and 3 deletions

View File

@ -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"

View File

@ -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"