test(conformance) : sonde des règles 1.2 que le moteur ne provoque pas

Quatre fonctions, une connexion de SONDE sur l'optimiseur (sans le moteur),
pour ce que le client du moteur n'envoie jamais :

- testGetPlanBeforeTheHandshakeIsRefused (Q3) : -32600 à son id, puis le
  handshake sur la même connexion est accepté (connexion gardée) ;
- testARefusedHandshakeIsClosedByTheOptimizer (Q3, Q4) : autre majeure →
  -32602 puis l'optimiseur ferme ; avec un token attendu, token faux →
  -32001, fermé pareil ;
- testABatchIsRefused (Q8) : un lot → une erreur -32600 à id null, son
  contenu non traité, connexion gardée ;
- testALineOverTheLimitIsRefused (Q7) : maxLineBytes + 1 → -32600 à id null,
  une ligne d'exactement maxLineBytes est lue.

Le traitement d'un message se constate par une réponse normée sans contexte :
UpdateLearned avec id → -32601 (Q6). Réponses lues brutes : un lot renvoyé
se verrait. README : tableau, boucle de lancement, ce qui n'est pas vérifié.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-10-21 14:01:35 +02:00
parent 4cbbb81802
commit 7bb2b4a194
2 changed files with 251 additions and 13 deletions

View File

@ -11,9 +11,18 @@ adaptateurs, sur les mocks de la suite `simulation` — et vérifie, une fonctio
| `testTheSessionMatchesTheContract` | la session entière (les deux sens) est conforme au contrat OpenRPC **embarqué** dans le moteur, règles sémantiques comprises (`tools/etm-powersync-schema-check --semantic`) | | `testTheSessionMatchesTheContract` | la session entière (les deux sens) est conforme au contrat OpenRPC **embarqué** dans le moteur, règles sémantiques comprises (`tools/etm-powersync-schema-check --semantic`) |
| `testEveryPlanIsAccepted` | à chaque cycle, le plan est **appliqué** : l'optimiseur commande, `GetPlan` sans `fallbackCode` | | `testEveryPlanIsAccepted` | à chaque cycle, le plan est **appliqué** : l'optimiseur commande, `GetPlan` sans `fallbackCode` |
| `testTheSafetyLoopStaysOnTop` | la boucle de sécurité reste au-dessus : une consigne hors bornes (réécrite en 100 kW par le relais de test) est **écrêtée** par l'adaptateur (`clamped`), une surcharge de phase est **délestée** par L4 | | `testTheSafetyLoopStaysOnTop` | la boucle de sécurité reste au-dessus : une consigne hors bornes (réécrite en 100 kW par le relais de test) est **écrêtée** par l'adaptateur (`clamped`), une surcharge de phase est **délestée** par L4 |
| `testGetPlanBeforeTheHandshakeIsRefused` | protocole 1.2, §3 : `GetPlan` avant un handshake réussi → erreur `-32600` à son `id`, connexion **gardée** (le handshake qui suit est accepté) |
| `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 |
| `testALineOverTheLimitIsRefused` | protocole 1.2, §2 : une ligne de `maxLineBytes` + 1 octets → `-32600` à `id: null`, connexion gardée ; une ligne d'exactement `maxLineBytes` est lue |
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**.
Les fonctions 6 à 9 ne branchent pas le moteur : elles envoient, par une **connexion de sonde**, ce
que son client n'envoie jamais, et lisent la réponse brute (un lot renvoyé se verrait). Elles
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é.
Les fonctions 2 à 5 passent par un relais transparent (une ligne reçue = une ligne renvoyée) qui Les fonctions 2 à 5 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,
@ -30,7 +39,8 @@ cd <build>/tests/auto/conformance
export NYMEA_PLUGINS_PATH=<build>/tests/mocks/plugins/energymocks:/usr/lib/x86_64-linux-gnu/nymea/plugins export NYMEA_PLUGINS_PATH=<build>/tests/mocks/plugins/energymocks:/usr/lib/x86_64-linux-gnu/nymea/plugins
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; do testEveryPlanIsAccepted testTheSafetyLoopStaysOnTop testGetPlanBeforeTheHandshakeIsRefused \
testARefusedHandshakeIsClosedByTheOptimizer testABatchIsRefused testALineOverTheLimitIsRefused; 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"
@ -65,7 +75,7 @@ toujours est conforme. Avec `ETM_POWERSYNC_CONFORMANCE_EXPECT=abstain` :
- `testTheSafetyLoopStaysOnTop` vérifie toujours L4 sous le rule-based, sans consigne de - `testTheSafetyLoopStaysOnTop` vérifie toujours L4 sous le rule-based, sans consigne de
l'optimiseur : la trace doit porter `shed` (la seule baisse des consignes ne suffit pas, l'import l'optimiseur : la trace doit porter `shed` (la seule baisse des consignes ne suffit pas, l'import
la produit déjà). Puis la fonction se termine en **SKIP** : aucune consigne à écrêter ; la produit déjà). Puis la fonction se termine en **SKIP** : aucune consigne à écrêter ;
- les trois autres fonctions sont inchangées. - les autres fonctions sont inchangées.
À l'inverse, un optimiseur qui s'abstient en mode `plan` échoue à `testEveryPlanIsAccepted` et À l'inverse, un optimiseur qui s'abstient en mode `plan` échoue à `testEveryPlanIsAccepted` et
`testTheSafetyLoopStaysOnTop`. Chaque fonction affiche en tête le mode retenu (`[Conformité] mode `testTheSafetyLoopStaysOnTop`. Chaque fonction affiche en tête le mode retenu (`[Conformité] mode
@ -81,6 +91,9 @@ fondée sur ces champs jugerait faux dans les deux cas : le mode se donne, il ne
## Ce qu'elle ne vérifie pas ## Ce qu'elle ne vérifie pas
La **qualité** des plans (aucune comparaison au rule-based : c'est `refoptimizer`, pour la référence La **qualité** des plans (aucune comparaison au rule-based : c'est `refoptimizer`, pour la référence
seulement), les bornes VE (aucune borne au banc), `UpdateLearned` (ignoré par le moteur 1.1), le seulement), les bornes VE (aucune borne au banc), `UpdateLearned` émis par l'optimiseur (ignoré par le moteur ; sa
comportement sous défaut de transport. Le contrat : `docs/etm-powersync-optimizer-protocol.openrpc.json` forme n'est pas normée en 1.2), le token envoyé à un optimiseur qui n'en exige pas (§3, 1.2 : accepté ;
le moteur n'envoie de token que si l'installateur en a posé un), la réponse à un contexte `GetPlan`
invalide (§7, 1.2 : `-32602` ; la forme d'`error.data` n'est pas normée), le comportement sous défaut
de transport. Le contrat : `docs/etm-powersync-optimizer-protocol.openrpc.json`
(prose : `docs/OPTIMIZER_PROTOCOL.md`, annexe G). (prose : `docs/OPTIMIZER_PROTOCOL.md`, annexe G).

View File

@ -16,7 +16,11 @@
// sémantiques comprises — jugée par le validateur publié ; // sémantiques comprises — jugée par le validateur publié ;
// 4. chaque plan ACCEPTÉ : l'optimiseur commande, `GetPlan` sans `fallbackCode` ; // 4. chaque plan ACCEPTÉ : l'optimiseur commande, `GetPlan` sans `fallbackCode` ;
// 5. la boucle de sécurité au-dessus : une consigne hors bornes est écrêtée par l'adaptateur, // 5. la boucle de sécurité au-dessus : une consigne hors bornes est écrêtée par l'adaptateur,
// une surcharge de phase est délestée par L4, quoi que dise l'optimiseur. // une surcharge de phase est délestée par L4, quoi que dise l'optimiseur ;
// 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
// 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).
// //
// 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
@ -52,6 +56,7 @@
#include <QTemporaryDir> #include <QTemporaryDir>
#include <csignal> #include <csignal>
#include <memory>
#include <unistd.h> #include <unistd.h>
using namespace nymeaserver; using namespace nymeaserver;
@ -319,6 +324,69 @@ private:
QElapsedTimer m_chrono; QElapsedTimer m_chrono;
}; };
/*!
* Connexion de SONDE (protocole 1.2) : des lignes écrites à la main, les réponses lues une à une,
* sans le moteur — pour ce que son client n'envoie jamais. Une réponse est gardée BRUTE aussi : un
* lot renvoyé (`[…]`) n'est pas un objet, et QJsonDocument::object() le rendrait vide.
*/
class Sonde
{
public:
explicit Sonde(const QString &endpoint) : m_s(socketVers(endpoint, &m_proprietaire)) {}
bool ouverte() { return m_s && waitFor([this] { return connecte(m_s); }, 5000); }
void envoyer(const QByteArray &ligne)
{
m_s->write(ligne + '\n');
if (auto *l = qobject_cast<QLocalSocket *>(m_s))
l->flush();
else if (auto *t = qobject_cast<QTcpSocket *>(m_s))
t->flush();
}
//! La ligne suivante (sans son '\n') ; faux si rien n'arrive en \p ms, ou si la connexion se ferme avant.
bool recevoir(QByteArray *ligne, int ms = 5000)
{
waitFor([this] { lire(); return m_tampon.contains('\n') || !connecte(m_s); }, ms);
lire();
const int nl = m_tampon.indexOf('\n');
if (nl < 0)
return false;
*ligne = m_tampon.left(nl);
m_tampon.remove(0, nl + 1);
return true;
}
//! Vrai si l'AUTRE côté ferme en \p ms : la sonde ne ferme jamais d'elle-même.
bool fermeeParLautre(int ms) { return waitFor([this] { lire(); return !connecte(m_s); }, ms); }
private:
void lire() { m_tampon += m_s->readAll(); }
QObject m_proprietaire;
QIODevice *m_s;
QByteArray m_tampon;
};
//! Une réponse error de la sonde : `{"jsonrpc":"2.0","id":<id>,"error":{"code":<code>,…}}`, objet seul.
QString verifierErreur(const QByteArray &ligne, const QJsonValue &id, int code)
{
QJsonParseError e;
const QJsonDocument doc = QJsonDocument::fromJson(ligne, &e);
const QString vu = QString::fromUtf8(ligne.left(300));
if (e.error != QJsonParseError::NoError || !doc.isObject())
return QStringLiteral("réponse qui n'est pas UN objet JSON : ") + vu;
const QJsonObject m = doc.object();
if (!m.contains("error") || m.value("error").toObject().value("code").toInt() != code)
return QStringLiteral("erreur %1 attendue : ").arg(code) + vu;
const QJsonValue recu = m.value("id");
const bool bonId = id.isNull() ? m.contains("id") && recu.isNull() : recu.isDouble() && recu.toDouble() == id.toDouble();
if (!bonId)
return QStringLiteral("id %1 attendu : ").arg(id.isNull() ? QStringLiteral("null") : QString::number(id.toInt())) + vu;
return QString();
}
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));
@ -336,9 +404,23 @@ private slots:
void testTheSessionMatchesTheContract(); void testTheSessionMatchesTheContract();
void testEveryPlanIsAccepted(); void testEveryPlanIsAccepted();
void testTheSafetyLoopStaysOnTop(); void testTheSafetyLoopStaysOnTop();
void testGetPlanBeforeTheHandshakeIsRefused();
void testARefusedHandshakeIsClosedByTheOptimizer();
void testABatchIsRefused();
void testALineOverTheLimitIsRefused();
private: private:
void monter(); void monter();
//! Où l'optimiseur écoute ($ETM_POWERSYNC_OPTIMIZER_ENDPOINT, sinon un socket du test).
QString endpoint() const;
//! Le token que l'optimiseur attend ($ETM_POWERSYNC_OPTIMIZER_TOKEN_FILE), vide sinon.
QString lireToken();
//! Points 6 à 9 : l'optimiseur seul, sans moteur ; \p sonde ouverte dessus.
void sonder(std::unique_ptr<Sonde> &sonde);
//! Un Handshake de sonde en \p version, avec \p token s'il n'est pas vide.
QByteArray handshake(int id, const QString &version, const QString &token) const;
//! Handshake accepté sur \p sonde, ou échec du test.
void ouvrirSession(Sonde &sonde, int id);
//! 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);
@ -437,20 +519,32 @@ void TestOptimizerConformance::monter()
m_arb->setOptimizerTraceDirectory(d); m_arb->setOptimizerTraceDirectory(d);
QCOMPARE(rpc("SetOptimizerTrace", {{"enabled", true}}).value("energyError").toString(), QString("EnergyErrorNoError")); QCOMPARE(rpc("SetOptimizerTrace", {{"enabled", true}}).value("energyError").toString(), QString("EnergyErrorNoError"));
// Token : celui que l'optimiseur attend, s'il en attend un. // Token : celui que l'optimiseur attend, s'il en attend un.
const QString fichier = variable("ETM_POWERSYNC_OPTIMIZER_TOKEN_FILE"); m_token = lireToken();
if (!fichier.isEmpty()) { if (QTest::currentTestFailed())
QFile f(fichier); return;
QVERIFY2(f.open(QIODevice::ReadOnly), qUtf8Printable("ETM_POWERSYNC_OPTIMIZER_TOKEN_FILE illisible : " + fichier)); if (!m_token.isEmpty())
m_token = QString::fromUtf8(f.readAll().trimmed());
QCOMPARE(rpc("SetOptimizerToken", {{"token", m_token}}).value("energyError").toString(), QString("EnergyErrorNoError")); QCOMPARE(rpc("SetOptimizerToken", {{"token", m_token}}).value("energyError").toString(), QString("EnergyErrorNoError"));
}
m_surplusW = 0; m_surplusW = 0;
} }
QString TestOptimizerConformance::endpoint() const
{
return variable("ETM_POWERSYNC_OPTIMIZER_ENDPOINT", QStringLiteral("unix://%1/optimiseur.sock").arg(m_dir.path()));
}
QString TestOptimizerConformance::lireToken()
{
const QString fichier = variable("ETM_POWERSYNC_OPTIMIZER_TOKEN_FILE");
if (fichier.isEmpty())
return QString();
QFile f(fichier);
[&] { QVERIFY2(f.open(QIODevice::ReadOnly), qUtf8Printable("ETM_POWERSYNC_OPTIMIZER_TOKEN_FILE illisible : " + fichier)); }();
return QString::fromUtf8(f.readAll().trimmed());
}
void TestOptimizerConformance::brancher(bool parLeRelais) void TestOptimizerConformance::brancher(bool parLeRelais)
{ {
const QString endpoint = variable("ETM_POWERSYNC_OPTIMIZER_ENDPOINT", const QString endpoint = this->endpoint();
QStringLiteral("unix://%1/optimiseur.sock").arg(m_dir.path()));
QString erreur; QString erreur;
QVERIFY2(m_optimiseur.demarrer(endpoint, &erreur), qUtf8Printable(erreur)); QVERIFY2(m_optimiseur.demarrer(endpoint, &erreur), qUtf8Printable(erreur));
QString cible = endpoint; QString cible = endpoint;
@ -710,5 +804,136 @@ void TestOptimizerConformance::testTheSafetyLoopStaysOnTop()
} }
} }
// --- 6 à 9 : protocole 1.2, par une connexion de sonde ----------------------------------------------
void TestOptimizerConformance::sonder(std::unique_ptr<Sonde> &sonde)
{
QVERIFY(m_dir.isValid());
m_token = lireToken();
if (QTest::currentTestFailed())
return;
QString erreur;
QVERIFY2(m_optimiseur.demarrer(endpoint(), &erreur), qUtf8Printable(erreur));
sonde.reset(new Sonde(endpoint()));
QVERIFY2(sonde->ouverte(), qUtf8Printable("la sonde ne se connecte pas à " + endpoint()));
}
QByteArray TestOptimizerConformance::handshake(int id, const QString &version, const QString &token) const
{
QJsonObject params{{"protocolVersion", version}, {"engine", "etm-powersync-energy-plugin-etm/conformance"}};
if (!token.isEmpty())
params.insert("token", token);
return QJsonDocument(QJsonObject{{"jsonrpc", "2.0"}, {"id", id}, {"method", "Handshake"}, {"params", params}})
.toJson(QJsonDocument::Compact);
}
void TestOptimizerConformance::ouvrirSession(Sonde &sonde, int id)
{
sonde.envoyer(handshake(id, QString::fromLatin1(OptimizerProtocol::ProtocolVersion), m_token));
QByteArray ligne;
QVERIFY2(sonde.recevoir(&ligne), "handshake de sonde sans réponse");
const QJsonObject m = QJsonDocument::fromJson(ligne).object();
QVERIFY2(m.value("id").toInt() == id && m.value("result").toObject().value("protocolVersion").isString(),
qUtf8Printable("handshake de sonde refusé : " + QString::fromUtf8(ligne.left(300))));
}
void TestOptimizerConformance::testGetPlanBeforeTheHandshakeIsRefused()
{
// §3 (1.2) : GetPlan avant un handshake réussi → -32600, et la connexion est GARDÉE — le
// handshake qui suit, sur la même connexion, est accepté.
std::unique_ptr<Sonde> sonde;
sonder(sonde);
if (QTest::currentTestFailed())
return;
sonde->envoyer(R"({"jsonrpc":"2.0","id":1,"method":"GetPlan","params":{}})");
QByteArray ligne;
QVERIFY2(sonde->recevoir(&ligne), "GetPlan avant le handshake : aucune réponse (ou connexion fermée)");
const QString e = verifierErreur(ligne, 1, -32600);
QVERIFY2(e.isEmpty(), qUtf8Printable(e));
ouvrirSession(*sonde, 2);
}
void TestOptimizerConformance::testARefusedHandshakeIsClosedByTheOptimizer()
{
// §3 (1.2) : un handshake refusé est fermé des deux côtés. Ici, la moitié de l'OPTIMISEUR : il
// ferme après avoir envoyé l'erreur (la sonde, elle, ne ferme pas). Majeure incompatible →
// -32602 ; si l'optimiseur attend un token, un token faux → -32001, fermé pareil. La moitié du
// moteur est éprouvée par la suite `optimizerclient` (testARefusedHandshakeIsClosedByTheEngine).
std::unique_ptr<Sonde> sonde;
sonder(sonde);
if (QTest::currentTestFailed())
return;
const QString autreMajeure = QString::number(QString::fromLatin1(OptimizerProtocol::ProtocolVersion).section('.', 0, 0).toInt() + 1) + ".0";
sonde->envoyer(handshake(1, autreMajeure, m_token));
QByteArray ligne;
QVERIFY2(sonde->recevoir(&ligne), qUtf8Printable("handshake en " + autreMajeure + " : aucune réponse avant la fermeture"));
QString e = verifierErreur(ligne, 1, -32602);
QVERIFY2(e.isEmpty(), qUtf8Printable(e));
QVERIFY2(sonde->fermeeParLautre(5000), "handshake refusé (-32602) : l'optimiseur n'a pas fermé la connexion en 5 s");
if (m_token.isEmpty()) {
qInfo().noquote() << "[Conformité] aucun token attendu : refus -32001 non éprouvé";
return;
}
Sonde faux(endpoint());
QVERIFY(faux.ouverte());
faux.envoyer(handshake(1, QString::fromLatin1(OptimizerProtocol::ProtocolVersion), m_token + QStringLiteral("-faux")));
QVERIFY2(faux.recevoir(&ligne), "token faux : aucune réponse avant la fermeture");
e = verifierErreur(ligne, 1, -32001);
QVERIFY2(e.isEmpty(), qUtf8Printable(e));
QVERIFY2(faux.fermeeParLautre(5000), "handshake refusé (-32001) : l'optimiseur n'a pas fermé la connexion en 5 s");
}
void TestOptimizerConformance::testABatchIsRefused()
{
// §2 (1.2) : lots interdits. Un lot reçu → UNE réponse -32600 à id null, aucun de ses messages
// traité, connexion gardée. Le lot porte une requête dont la réponse est normée (UpdateLearned
// avec id → -32601, §8) : la traiter se verrait.
std::unique_ptr<Sonde> sonde;
sonder(sonde);
if (QTest::currentTestFailed())
return;
ouvrirSession(*sonde, 1);
if (QTest::currentTestFailed())
return;
sonde->envoyer(R"([{"jsonrpc":"2.0","id":2,"method":"UpdateLearned","params":{}}])");
QByteArray ligne;
QVERIFY2(sonde->recevoir(&ligne), "lot : aucune réponse (ou connexion fermée)");
QString e = verifierErreur(ligne, QJsonValue::Null, -32600);
QVERIFY2(e.isEmpty(), qUtf8Printable(e));
// Connexion gardée, et la ligne suivante répond à la requête suivante — pas au contenu du lot.
sonde->envoyer(R"({"jsonrpc":"2.0","id":3,"method":"UpdateLearned","params":{}})");
QVERIFY2(sonde->recevoir(&ligne), "après le lot : aucune réponse (ou connexion fermée)");
e = verifierErreur(ligne, 3, -32601);
QVERIFY2(e.isEmpty(), qUtf8Printable(e));
}
void TestOptimizerConformance::testALineOverTheLimitIsRefused()
{
// §2 (1.2) : maxLineBytes dans les deux sens, '\n' non compté. Un octet de trop → -32600 à id
// null, la ligne n'est pas traitée, connexion gardée ; une ligne d'exactement maxLineBytes est
// lue normalement.
std::unique_ptr<Sonde> sonde;
sonder(sonde);
if (QTest::currentTestFailed())
return;
ouvrirSession(*sonde, 1);
if (QTest::currentTestFailed())
return;
auto ligneDe = [](int id, int taille) {
QByteArray l = QStringLiteral(R"({"jsonrpc":"2.0","id":%1,"method":"UpdateLearned","params":{}})").arg(id).toUtf8();
return l + QByteArray(taille - l.size(), ' ');
};
sonde->envoyer(ligneDe(2, OptimizerClient::kMaxLineBytes + 1));
QByteArray ligne;
QVERIFY2(sonde->recevoir(&ligne, 10000), "ligne trop longue : aucune réponse (ou connexion fermée)");
QString e = verifierErreur(ligne, QJsonValue::Null, -32600);
QVERIFY2(e.isEmpty(), qUtf8Printable(e));
sonde->envoyer(ligneDe(3, OptimizerClient::kMaxLineBytes));
QVERIFY2(sonde->recevoir(&ligne, 10000), "ligne d'exactement maxLineBytes : aucune réponse (ou connexion fermée)");
e = verifierErreur(ligne, 3, -32601);
QVERIFY2(e.isEmpty(), qUtf8Printable(e));
}
QTEST_MAIN(TestOptimizerConformance) QTEST_MAIN(TestOptimizerConformance)
#include "testconformance.moc" #include "testconformance.moc"