test(optimizerclient) : le moteur face aux règles 1.2 — fermeture, lots, longueur de ligne

Preuves, côté moteur, des points Q4, Q7 et Q8 du protocole 1.2. Aucun code du
moteur n'est modifié.

- testARefusedHandshakeIsClosedByTheEngine (Q4) : -32001 ou -32602 au
  handshake, faux optimiseur qui garde son socket ouvert → c'est le moteur qui
  ferme (handshake-refused), nouvelle tentative au backoff seulement.
- testAReceivedBatchIsNeverUnpacked (Q8, reçu) : un lot portant la réponse
  attendue est une ligne écartée (invalid-json), connexion conservée, réponse
  non lue ; la requête finit en délai dépassé.
- testEachLineSentIsOneObject (Q8, émis) : lu sur les octets, chaque ligne
  émise est UN objet JSON, une requête remplacée donne une ligne de plus.
- testAContextOverTheLineLimitIsStillSent (Q7, émis) : ÉCART constaté, non
  corrigé dans ce lot — requestPlan() ne borne pas ce qu'il émet ; un GetPlan
  de plus de 1 Mio part tel quel. QEXPECT_FAIL : le test passera en XPASS le
  jour où le moteur bornera l'émission.

Q7 en réception était déjà éprouvé par testLineLengthLimit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-10-21 14:01:16 +02:00
parent e56485d014
commit 952f64d24b

View File

@ -52,6 +52,37 @@ int countLog(const QString &needle)
return n; return n;
} }
/*! Optimiseur BRUT : ce que le client écrit, octet par octet, sans le découpage du faux
* optimiseur (qui ne rend que des objets, et ne verrait donc pas un lot). */
struct RawOptimizer
{
QLocalServer server;
QLocalSocket *socket = nullptr;
QByteArray received;
QString listen(const QString &path)
{
QLocalServer::removeServer(path);
return server.listen(path) ? QStringLiteral("unix://") + path : QString();
}
//! Lignes complètes reçues jusqu'ici, dans l'ordre (sans leur '\n').
QList<QByteArray> lines()
{
if (!socket && server.hasPendingConnections())
socket = server.nextPendingConnection();
if (socket)
received += socket->readAll();
QList<QByteArray> l = received.split('\n');
l.removeLast(); // ce qui suit le dernier '\n' : vide, ou une ligne pas encore finie
return l;
}
void send(const QByteArray &line)
{
socket->write(line + '\n');
socket->flush();
}
};
} // namespace } // namespace
/*! Horloge simulée : le temps n'avance que par advance(), et les réveils échus sonnent dans /*! Horloge simulée : le temps n'avance que par advance(), et les réveils échus sonnent dans
@ -112,6 +143,11 @@ private slots:
void testWriteToken(); void testWriteToken();
void testTokenNeverInAnyLog(); void testTokenNeverInAnyLog();
void testAShortTokenFileOpensNoConnection(); void testAShortTokenFileOpensNoConnection();
void testARefusedHandshakeIsClosedByTheEngine_data();
void testARefusedHandshakeIsClosedByTheEngine();
void testAReceivedBatchIsNeverUnpacked();
void testEachLineSentIsOneObject();
void testAContextOverTheLineLimitIsStillSent();
private: private:
// Démarre le client et mène le handshake jusqu'à Ready. // Démarre le client et mène le handshake jusqu'à Ready.
@ -847,5 +883,133 @@ void TestOptimizerClient::testAShortTokenFileOpensNoConnection()
QCOMPARE(server.connections, 1); QCOMPARE(server.connections, 1);
} }
void TestOptimizerClient::testARefusedHandshakeIsClosedByTheEngine_data()
{
QTest::addColumn<int>("code");
QTest::newRow("token refusé, -32001") << -32001;
QTest::newRow("majeure incompatible, -32602") << -32602;
}
void TestOptimizerClient::testARefusedHandshakeIsClosedByTheEngine()
{
// Protocole 1.2 (Q4) : après un handshake refusé, les DEUX côtés ferment. Ce faux optimiseur
// envoie l'erreur et GARDE son socket ouvert : la déconnexion qu'il constate vient du moteur.
QFETCH(int, code);
FakeOptimizer server(FakeOptimizer::Unix, m_dir.path());
QVERIFY(server.listen());
SimClock clock;
OptimizerClient client(OptimizerEndpoint::parse(server.endpoint()), QString(), "9.9", &clock);
client.start();
QJsonObject hs;
QVERIFY(server.waitMessage(&hs));
server.sendJson(QJsonObject{{"jsonrpc", "2.0"}, {"id", hs.value("id")},
{"error", QJsonObject{{"code", code}, {"message", "refusé"}}}});
QVERIFY(waitFor([&] { return server.disconnections == 1; }));
QCOMPARE(client.state(), OptimizerClient::Waiting);
QCOMPARE(client.lastErrorCode(), QStringLiteral("handshake-refused"));
QCOMPARE(client.requestPlan({}), quint64(0));
// Nouvelle tentative au backoff, pas avant : la fermeture n'est pas une reconnexion immédiate.
settle();
QCOMPARE(server.connections, 1);
QCOMPARE(clock.remaining(), qint64(1000));
}
void TestOptimizerClient::testAReceivedBatchIsNeverUnpacked()
{
// Protocole 1.2 (Q8) : lots interdits dans les deux sens. REÇU, un lot est une ligne écartée
// (invalid-json : ce n'est pas un objet), comptée, connexion CONSERVÉE ; la réponse qu'il
// contient n'est pas lue. La requête en vol attend donc toujours, et son délai la compte en échec.
FakeOptimizer server(FakeOptimizer::Unix, m_dir.path());
QVERIFY(server.listen());
SimClock clock;
OptimizerClient client(OptimizerEndpoint::parse(server.endpoint()), QString(), "9.9", &clock);
bringUp(client, server);
QSignalSpy planSpy(&client, &OptimizerClient::planReceived);
QSignalSpy failSpy(&client, &OptimizerClient::planFailed);
QSignalSpy errorSpy(&client, &OptimizerClient::protocolError);
const quint64 id = client.requestPlan({});
QJsonObject req;
QVERIFY(server.waitMessage(&req));
const QByteArray reponse = QJsonDocument(QJsonObject{{"jsonrpc", "2.0"}, {"id", qint64(id)},
{"result", QJsonObject{{"planId", "dans-un-lot"}}}})
.toJson(QJsonDocument::Compact);
server.sendRaw("[" + reponse + "]\n");
QVERIFY(waitFor([&] { return errorSpy.count() == 1; }));
QCOMPARE(errorSpy.at(0).at(0).toString(), QStringLiteral("invalid-json"));
settle();
QCOMPARE(planSpy.count(), 0);
QCOMPARE(client.pendingRequestId(), id);
QCOMPARE(client.state(), OptimizerClient::Ready);
clock.advance(OptimizerClient::kRequestTimeoutMs);
QCOMPARE(failSpy.count(), 1);
QCOMPARE(failSpy.at(0).at(1).toString(), QStringLiteral("timeout"));
settle();
QCOMPARE(client.state(), OptimizerClient::Ready);
QCOMPARE(server.disconnections, 0);
}
void TestOptimizerClient::testEachLineSentIsOneObject()
{
// Protocole 1.2 (Q8, sens moteur → optimiseur) : un message par ligne, jamais un lot. Le client
// n'a qu'un point d'écriture, send(), qui sérialise UN objet ; vérifié ici sur les octets.
RawOptimizer raw;
const QString endpoint = raw.listen(m_dir.filePath("brut.sock"));
QVERIFY(!endpoint.isEmpty());
SimClock clock;
OptimizerClient client(OptimizerEndpoint::parse(endpoint), QString(), "9.9", &clock);
client.start();
QVERIFY(waitFor([&] { return raw.lines().size() == 1; }));
raw.send(R"({"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"1.2"}})");
QVERIFY(waitFor([&] { return client.state() == OptimizerClient::Ready; }));
const QVariantMap context{{"timestamp", "2026-10-16T10:00:00Z"},
{"loads", QVariantList{QVariantMap{{"id", "a"}}, QVariantMap{{"id", "b"}}}}};
QCOMPARE(client.requestPlan(context), quint64(2));
QCOMPARE(client.requestPlan(context), quint64(3)); // remplace la précédente : une ligne de plus, pas un lot
QVERIFY(waitFor([&] { return raw.lines().size() == 3; }));
const QList<QByteArray> lines = raw.lines();
QVERIFY(raw.received.endsWith('\n'));
for (int i = 0; i < lines.size(); ++i) {
QJsonParseError e;
const QJsonDocument doc = QJsonDocument::fromJson(lines.at(i), &e);
QVERIFY2(e.error == QJsonParseError::NoError && doc.isObject() && lines.at(i).startsWith('{'),
qPrintable(QStringLiteral("ligne %1 : pas un objet JSON seul").arg(i + 1)));
QCOMPARE(doc.object().value("id").toInteger(), qint64(i + 1));
QVERIFY(lines.at(i).size() <= OptimizerClient::kMaxLineBytes);
}
}
void TestOptimizerClient::testAContextOverTheLineLimitIsStillSent()
{
// ÉCART au protocole 1.2 (Q7, maxLineBytes dans les deux sens), constaté et NON CORRIGÉ dans le
// lot qui norme la règle : requestPlan() n'applique pas kMaxLineBytes à ce qu'il ÉMET. Rien ne
// borne non plus la taille d'un contexte en amont — un `label` de charge saisi par SetLoadConfig
// suffit. Un optimiseur conforme jette la ligne (-32600) et la requête finit en délai dépassé.
// Le jour où le moteur borne l'émission, ce test passe (XPASS) et doit être réécrit.
RawOptimizer raw;
const QString endpoint = raw.listen(m_dir.filePath("brut.sock"));
QVERIFY(!endpoint.isEmpty());
SimClock clock;
OptimizerClient client(OptimizerEndpoint::parse(endpoint), QString(), "9.9", &clock);
client.start();
QVERIFY(waitFor([&] { return raw.lines().size() == 1; }));
raw.send(R"({"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"1.2"}})");
QVERIFY(waitFor([&] { return client.state() == OptimizerClient::Ready; }));
const QVariantMap context{{"loads", QVariantList{QVariantMap{
{"id", "ecs"}, {"label", QString(OptimizerClient::kMaxLineBytes, QLatin1Char('x'))}}}}};
QCOMPARE(client.requestPlan(context), quint64(2));
QVERIFY(waitFor([&] { return raw.lines().size() == 2; }, 10000));
const QByteArray line = raw.lines().at(1);
QVERIFY(line.contains("\"GetPlan\""));
QEXPECT_FAIL("", "écart Q7 : le moteur émet une ligne de plus de maxLineBytes (rapport protocole 1.2)", Continue);
QVERIFY2(line.size() <= OptimizerClient::kMaxLineBytes,
qPrintable(QStringLiteral("GetPlan émis sur %1 octets").arg(line.size())));
}
QTEST_GUILESS_MAIN(TestOptimizerClient) QTEST_GUILESS_MAIN(TestOptimizerClient)
#include "testoptimizerclient.moc" #include "testoptimizerclient.moc"