etm-powersync-energy-plugin.../tests/auto/common/etm-powersync-fakeoptimizer.h
Patrick Schurig ce323a6e1f fix(optimiseur): une tentative de reconnexion n'est pas une nouvelle perte (correctifs brief 4, point 2)
Constat `drop` de T7 : SocketScheduler notait ConnexionPerdue à CHAQUE état
non connecté du client (Connecting, Waiting d'une tentative), réécrivait
lastErrorCode avec celui du client et émettait etatChange() à chaque
tentative au backoff (une par minute au plafond).

La perte se note désormais à la transition : sortie de Ready ou de
Handshaking (fermeture, handshake refusé), ou premier échec d'un client qui
n'a jamais été connecté (sinon un optimiseur absent dès le départ
n'aurait jamais de lastErrorCode). Les tentatives suivantes d'une panne déjà
notée ne touchent ni au dernier code, ni aux échecs, ni à l'état publié.
Publication seule : aucune décision ne change.

Test socketscheduler::testAReconnectionAttemptIsNotANewLoss : optimiseur
disparu, 3 tentatives au backoff → 1 seul etatChange, 1 seule notification,
lastErrorCode `connection-lost` (le client en est à `connect-failed`) ; au
retour, les transitions du §7 et rien d'autre. Ligne `drop` de refoptimizer :
le dernier code reste `disconnected`. Contre-épreuve M2 (ancien comportement) :
mord sur les deux.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 14:34:38 +02:00

176 lines
5.8 KiB
C++

// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
//
// Faux optimiseur dans le processus de test (QLocalServer / QTcpServer) — extrait de la suite
// `optimizerclient` (brief 4 T2) pour être partagé avec `socketscheduler` (brief 4 T3).
#pragma once
#include <QCoreApplication>
#include <QElapsedTimer>
#include <QIODevice>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QLocalServer>
#include <QLocalSocket>
#include <QPointer>
#include <QTcpServer>
#include <QTcpSocket>
#include <QTest>
#include <functional>
// Attente d'une condition d'E/S. L'horloge murale ne borne QUE l'attente du test, jamais un
// délai du client.
template <typename F>
bool waitFor(F condition, int timeoutMs = 5000)
{
QElapsedTimer t;
t.start();
while (!condition()) {
if (t.elapsed() > timeoutMs)
return false;
QCoreApplication::processEvents(QEventLoop::AllEvents, 10);
}
return true;
}
// Laisse passer les événements en attente sans rien attendre de précis : sert à constater
// qu'il NE se passe rien.
inline void settle(int ms = 50)
{
QElapsedTimer t;
t.start();
while (t.elapsed() < ms)
QCoreApplication::processEvents(QEventLoop::AllEvents, 5);
}
/*! Faux optimiseur : accepte une connexion à la fois, découpe ce qu'il reçoit en lignes JSON,
* et répond ce que le test lui dicte. */
class FakeOptimizer : public QObject
{
Q_OBJECT
public:
enum Transport { Unix, Tcp };
FakeOptimizer(Transport transport, const QString &dir) : m_transport(transport), m_dir(dir) {}
bool listen()
{
if (m_transport == Unix) {
m_local = new QLocalServer(this);
QLocalServer::removeServer(socketPath());
connect(m_local, &QLocalServer::newConnection, this, [this] {
while (QLocalSocket *s = m_local->nextPendingConnection())
adopt(s);
});
return m_local->listen(socketPath());
}
m_tcp = new QTcpServer(this);
connect(m_tcp, &QTcpServer::newConnection, this, [this] {
while (QTcpSocket *s = m_tcp->nextPendingConnection())
adopt(s);
});
// Port déjà choisi lors d'une écoute précédente : on le reprend (reconnexion).
return m_tcp->listen(QHostAddress::LocalHost, m_port) && (m_port = m_tcp->serverPort()) != 0;
}
QString socketPath() const { return m_dir + QStringLiteral("/optimizer.sock"); }
QString endpoint() const
{
if (m_transport == Unix)
return QStringLiteral("unix://") + socketPath();
return QStringLiteral("tcp://127.0.0.1:%1").arg(m_port);
}
bool waitMessage(QJsonObject *out, int timeoutMs = 5000)
{
if (!waitFor([this] { return !m_messages.isEmpty(); }, timeoutMs))
return false;
*out = m_messages.takeFirst();
return true;
}
void sendRaw(const QByteArray &bytes)
{
QVERIFY(m_client);
m_client->write(bytes);
if (auto *tcp = qobject_cast<QTcpSocket *>(m_client.data()))
tcp->flush();
else if (auto *local = qobject_cast<QLocalSocket *>(m_client.data()))
local->flush();
}
void sendJson(const QJsonObject &o) { sendRaw(QJsonDocument(o).toJson(QJsonDocument::Compact) + '\n'); }
void replyResult(qint64 id, const QJsonObject &result)
{
sendJson(QJsonObject{{"jsonrpc", "2.0"}, {"id", id}, {"result", result}});
}
void abortClient()
{
if (auto *tcp = qobject_cast<QTcpSocket *>(m_client.data()))
tcp->abort();
else if (auto *local = qobject_cast<QLocalSocket *>(m_client.data()))
local->abort();
}
//! L'optimiseur disparaît : la connexion est coupée et le serveur cesse d'écouter — chaque
//! tentative du client échoue jusqu'au prochain listen() (même endpoint, même port).
void fermer()
{
abortClient();
delete m_local;
m_local = nullptr;
delete m_tcp;
m_tcp = nullptr;
}
int connections = 0;
int disconnections = 0;
//! Répondeur automatique (brief 4 T3) : appelé sur chaque message reçu ; s'il rend vrai, le
//! message est consommé et n'entre pas dans la file de waitMessage(). Vide = tout est filé.
std::function<bool(const QJsonObject &)> responder;
private:
void adopt(QIODevice *s)
{
++connections;
m_client = s;
m_buffer.clear();
connect(s, &QIODevice::readyRead, this, [this, s] {
m_buffer.append(s->readAll());
int nl;
while ((nl = m_buffer.indexOf('\n')) >= 0) {
const QJsonObject message = QJsonDocument::fromJson(m_buffer.left(nl)).object();
m_buffer.remove(0, nl + 1);
if (!(responder && responder(message)))
m_messages.append(message);
}
});
if (auto *tcp = qobject_cast<QTcpSocket *>(s))
connect(tcp, &QTcpSocket::disconnected, this, [this] { ++disconnections; });
else if (auto *local = qobject_cast<QLocalSocket *>(s))
connect(local, &QLocalSocket::disconnected, this, [this] { ++disconnections; });
}
Transport m_transport;
QString m_dir;
quint16 m_port = 0;
QLocalServer *m_local = nullptr;
QTcpServer *m_tcp = nullptr;
QPointer<QIODevice> m_client;
QByteArray m_buffer;
QList<QJsonObject> m_messages;
};
inline QJsonObject handshakeResult(const QString &version = QStringLiteral("1.1"), bool withCapabilities = true)
{
QJsonObject r{{"protocolVersion", version}, {"name", "faux-optimiseur"}, {"planning", true}};
if (withCapabilities)
r.insert("capabilities", QJsonObject{{"maxHorizonH", 48}, {"kinds", QJsonArray{"setpoint", "state"}}});
return r;
}