test: suite etm-powersync-protocolschema (contrat == docs == code)

Contrat embarqué identique à docs/ ; listes x-etm-generated égales au code
(ancre côté test, dump pour régénérer) ; chaque code de verdict, d'erreur et
de repli lu dans les SOURCES figure dans les listes ; règles sémantiques ==
verdicts du validateur, même ordre ; sérialiseur GetPlan jugé conforme sur
toutes ses branches par le validateur publié.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-10-17 21:30:44 +02:00
parent 053263bc38
commit d95a65cb71
4 changed files with 605 additions and 1 deletions

View File

@ -1,2 +1,2 @@
TEMPLATE = subdirs
SUBDIRS += charging decisions loadmodel optimizerclient refoptimizer simulation socketscheduler spotmarket
SUBDIRS += charging decisions loadmodel optimizerclient protocolschema refoptimizer simulation socketscheduler spotmarket

View File

@ -0,0 +1,102 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
//
// Juger des messages du protocole optimiseur contre son contrat OpenRPC (lot « contrat OpenRPC »),
// par le validateur du dépôt : tools/etm-powersync-schema-check, lancé en sous-processus Python.
//
// Un seul validateur, pas deux : un second, écrit en C++ pour les tests, divergerait du premier, et
// c'est la conformité au validateur PUBLIÉ (celui qu'utilise un auteur d'optimiseur) qui compte.
#pragma once
#include <QDir>
#include <QFile>
#include <QJsonDocument>
#include <QJsonObject>
#include <QList>
#include <QProcess>
#include <QTemporaryDir>
namespace Contrat {
/*! Racine des sources : surchargée par $ETM_POWERSYNC_SOURCE_DIR (arbre mutant, arbre archivé). */
inline QString racineSources()
{
const QByteArray env = qgetenv("ETM_POWERSYNC_SOURCE_DIR");
return env.isEmpty() ? QStringLiteral(ETM_POWERSYNC_SOURCE_DIR) : QString::fromLocal8Bit(env);
}
/*! Le contrat de docs/ — à défaut du document EMBARQUÉ (suites qui n'ont pas le sérialiseur). */
inline QString schemaDuDepot()
{
return racineSources() + QStringLiteral("/docs/etm-powersync-optimizer-protocol.openrpc.json");
}
/*! Verdict du validateur. */
struct Verdict {
int code = -1; //!< 0 conforme, 1 écart, 2 erreur d'usage ou de schéma, -1 pas lancé.
QJsonObject resume; //!< La ligne RÉSUMÉ (comptes, écarts, verdicts).
QString sortie; //!< Tout ce qu'il a écrit — à citer dans le message d'échec.
int compte(const QString &partie) const { return resume.value(QStringLiteral("comptes")).toObject().value(partie).toInt(); }
};
/*!
* Juge une session (un message JSON-RPC par élément, dans l'ordre) contre \p schema.
* \p arguments : options en plus (`--requests-only`, `--semantic`).
*/
inline Verdict juger(const QList<QJsonObject> &messages, const QString &schema, const QStringList &arguments = {})
{
Verdict v;
QTemporaryDir dir;
if (!dir.isValid()) {
v.sortie = QStringLiteral("répertoire temporaire impossible");
return v;
}
QFile f(dir.filePath(QStringLiteral("session.ndjson")));
if (!f.open(QIODevice::WriteOnly)) {
v.sortie = QStringLiteral("écriture de la session impossible");
return v;
}
for (const QJsonObject &m : messages)
f.write(QJsonDocument(m).toJson(QJsonDocument::Compact) + '\n');
f.close();
QProcess p;
p.setProcessChannelMode(QProcess::MergedChannels);
p.start(QStringLiteral("python3"),
QStringList{QStringLiteral("-I"), QStringLiteral("-B"),
racineSources() + QStringLiteral("/tools/etm-powersync-schema-check"),
QStringLiteral("--schema"), schema, QStringLiteral("--ndjson"), f.fileName()}
+ arguments);
if (!p.waitForFinished(60000)) {
p.kill();
v.sortie = QStringLiteral("validateur sans réponse en 60 s");
return v;
}
v.code = p.exitStatus() == QProcess::NormalExit ? p.exitCode() : -1;
v.sortie = QString::fromUtf8(p.readAll());
for (const QString &ligne : v.sortie.split(QLatin1Char('\n')))
if (ligne.startsWith(QStringLiteral("RÉSUMÉ ")))
v.resume = QJsonDocument::fromJson(ligne.mid(7).toUtf8()).object();
return v;
}
/*! Écrit \p octets dans \p dir et rend le chemin : le contrat EMBARQUÉ, passé au validateur. */
inline QString ecrireSchema(const QTemporaryDir &dir, const QByteArray &octets)
{
const QString chemin = dir.filePath(QStringLiteral("contrat-embarque.json"));
QFile f(chemin);
if (f.open(QIODevice::WriteOnly))
f.write(octets);
return chemin;
}
/*! Les messages de \p messages dont la méthode est \p methode. */
inline QList<QJsonObject> deMethode(const QList<QJsonObject> &messages, const QString &methode)
{
QList<QJsonObject> out;
for (const QJsonObject &m : messages)
if (m.value(QStringLiteral("method")).toString() == methode)
out << m;
return out;
}
}

View File

@ -0,0 +1,22 @@
# Lot « contrat OpenRPC » — le protocole optimiseur comme contrat machine : contrat embarqué ==
# docs/, listes fermées == code, règles sémantiques == verdicts du validateur, sérialiseur conforme.
#
# Cible LÉGÈRE, comme `loadmodel` : aucun serveur nymea, aucun arbitre. Le jugement passe par le
# validateur publié (tools/etm-powersync-schema-check, Python en sous-processus).
include(../common/common.pri)
include(../../../energyplugin/energyplugin.pri)
CONFIG += testcase
TARGET = etm-powersync-protocolschema
# Racine des sources : contrat de docs/, sources lues par le test, validateur Python. Surchargée à
# l'exécution par $ETM_POWERSYNC_SOURCE_DIR (arbre mutant, arbre archivé).
DEFINES += ETM_POWERSYNC_SOURCE_DIR=\\\"$$clean_path($$PWD/../../..)\\\"
HEADERS += $$PWD/../common/etm-powersync-contrat.h
SOURCES += testprotocolschema.cpp
target.path = $$[QT_INSTALL_PREFIX]/bin
INSTALLS += target

View File

@ -0,0 +1,480 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
//
// Lot « contrat OpenRPC » — le protocole optimiseur est un CONTRAT MACHINE
// (docs/etm-powersync-optimizer-protocol.openrpc.json), embarqué dans le plugin et servi par
// NymeaEnergy.GetOptimizerProtocol. Cette suite tient les deux bouts du fil :
// · le contrat embarqué est celui de docs/, octet pour octet ;
// · ses listes fermées sont celles du CODE (x-etm-generated), et le code n'émet aucun code de
// verdict, d'erreur ou de repli qui n'y figure pas — lu dans les SOURCES, pas dans une copie ;
// · ses règles sémantiques sont les verdicts du validateur, dans son ordre ;
// · le sérialiseur GetPlan, sur chacune de ses branches, produit ce que le contrat déclare —
// jugé par le validateur publié (tools/etm-powersync-schema-check), pas par un second.
// Logique pure : aucun serveur nymea, aucun arbitre (comme `loadmodel`).
#include "etm/scheduler/optimizerclient.h"
#include "etm/scheduler/optimizerplan.h"
#include "etm/scheduler/optimizerprotocol.h"
#include "etm/scheduler/socketscheduler.h"
#include "etm/tariff/tariffconfig.h"
#include "etm/types/decisionreason.h"
#include "etm/types/loadconfig.h"
#include "../common/etm-powersync-contrat.h"
#include <QFile>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QRegularExpression>
#include <QSet>
#include <QTemporaryDir>
#include <QTest>
#include <QTimeZone>
using namespace OptimizerProtocol;
namespace {
QJsonObject contrat()
{
return QJsonDocument::fromJson(schemaDocumentBytes()).object();
}
//! Valeur au pointeur JSON \p pointeur (RFC 6901, sans échappement : le contrat n'en a pas besoin).
QJsonValue aPointeur(const QJsonObject &doc, const QString &pointeur)
{
QJsonValue v = doc;
for (const QString &morceau : pointeur.mid(1).split(QLatin1Char('/'))) {
if (!v.isObject() || !v.toObject().contains(morceau))
return QJsonValue(QJsonValue::Undefined);
v = v.toObject().value(morceau);
}
return v;
}
QJsonArray tableau(const QStringList &l)
{
return QJsonArray::fromStringList(l);
}
/*!
* Les listes fermées du contrat, telles que le CODE les donne. La clé est le nom inscrit dans
* `x-etm-generated.items[].from` ; l'outil de génération réécrit le contrat depuis ce dictionnaire.
*/
QJsonObject listesDuCode()
{
QJsonObject cles;
for (const QString &code : DecisionCode::optimizerEmittableCodes())
cles.insert(code, tableau(DecisionCode::optimizerParamKeys(code)));
return QJsonObject{
{"OptimizerProtocol::ProtocolVersion", QString::fromLatin1(ProtocolVersion)},
{"SocketScheduler::knownTriggers()", tableau(SocketScheduler::knownTriggers())},
{"LoadConfig::knownDomains()", tableau(LoadConfig::knownDomains())},
{"OptimizerPlan::kinds()", tableau(OptimizerPlan::kinds())},
{"DecisionCode::optimizerEmittableCodes()", tableau(DecisionCode::optimizerEmittableCodes())},
{"DecisionCode::optimizerParamKeys()", cles},
{"TariffConfig::includes()", tableau(TariffConfig::includes())},
{"SocketScheduler::knownFallbackCodes()", tableau(SocketScheduler::knownFallbackCodes())},
{"SocketScheduler::knownLastErrorCodes()", tableau(SocketScheduler::knownLastErrorCodes())},
};
}
QString source(const QString &relatif)
{
QFile f(Contrat::racineSources() + QLatin1Char('/') + relatif);
if (!f.open(QIODevice::ReadOnly))
return QString();
return QString::fromUtf8(f.readAll());
}
//! Les captures \p groupe de \p motif dans \p texte.
QSet<QString> captures(const QString &texte, const QString &motif, int groupe = 1)
{
QSet<QString> out;
QRegularExpressionMatchIterator it = QRegularExpression(motif).globalMatch(texte);
while (it.hasNext())
out.insert(it.next().captured(groupe));
return out;
}
QSet<QString> ensemble(const QStringList &l)
{
return QSet<QString>(l.cbegin(), l.cend());
}
QString liste(const QSet<QString> &s)
{
QStringList l = s.values();
l.sort();
return l.join(QStringLiteral(", "));
}
// --- Contextes : chaque branche du sérialiseur ---------------------------------------------------
QDateTime instant()
{
return QDateTime(QDate(2026, 10, 12), QTime(13, 42), QTimeZone(7200));
}
LoadContext enWatts(const QString &id, const QString &adapter, int priorite)
{
LoadContext lc;
lc.id = id;
lc.adapter = adapter;
lc.label = id;
lc.priority = priorite;
lc.declared.minPowerW = adapter == QLatin1String("relay-router") ? 800 : 0;
lc.declared.maxPowerW = 2400;
lc.telemetry.currentPowerW = 1600;
lc.telemetry.lockMinPowerW = 1600;
lc.telemetry.lockMaxPowerW = -1;
lc.limits.minOnS = 300;
lc.limits.minOffS = 60;
return lc;
}
LoadContext borne(bool avecAppareil, bool sessionMesuree)
{
LoadContext lc;
lc.id = avecAppareil ? QStringLiteral("borne") : QStringLiteral("borne-sans-appareil");
lc.adapter = QStringLiteral("evcharger");
lc.label = lc.id;
lc.priority = 1;
if (avecAppareil) {
lc.declared.minA = 6;
lc.declared.maxA = 16;
lc.declared.phases = 3;
lc.telemetry.pluggedIn = true;
lc.telemetry.charging = true;
lc.telemetry.sessionMeasurable = sessionMesuree;
lc.telemetry.sessionWh = 5200;
lc.limits.chargingEnabledLockS = 300;
lc.limits.currentChangeLockS = 60;
lc.needs.targetSocPercent = 80;
lc.needs.deadline = QDateTime(QDate(2026, 10, 13), QTime(7, 0), QTimeZone(7200));
} else {
lc.telemetry.available = false;
}
lc.telemetry.currentPowerW = avecAppareil ? 4100 : 0;
return lc;
}
LoadContext pac()
{
LoadContext lc;
lc.id = QStringLiteral("pac");
lc.adapter = QStringLiteral("sg-ready");
lc.label = QStringLiteral("PAC");
lc.priority = 4;
lc.declared.states = {1, 2, 3, 4};
lc.declared.estimatedPowerW = {{3, 1800}, {4, 2600}};
lc.telemetry.state = 3;
lc.telemetry.minState = 3;
lc.telemetry.maxState = 3;
lc.limits.minStateHoldS = 900;
return lc;
}
QVariantMap instantaneComplet()
{
QVariantList points, base, prix;
for (int i = 0; i < 4; ++i) {
if (i != 2) // une case sans point : null sur le fil
points << QVariantMap{{"targetAt", QDateTime(QDate(2026, 10, 12), QTime(12 + i, 0), QTimeZone::UTC)
.toString(Qt::ISODate)},
{"forecastWh", 1000.0 * (4 - i)}};
base << (i == 1 ? QVariant() : QVariant(400.0 + i));
prix << 20.5;
}
return {
{"start", "2026-10-12T12:00:00Z"}, {"resolutionS", 3600}, {"steps", 4},
{"forecastPoints", points}, {"conversion", "physics"},
{"corrections", QStringList{"temperature", "ac-limit"}},
{"perField", QVariantList{QVariantMap{{"id", "plan-sud"}, {"corrections", QStringList{"temperature"}}}}},
{"model", "meteofrance_seamless"}, {"issuedAt", "2026-10-12T09:00:00Z"},
{"contentChangedAt", "2026-10-12T10:07:00Z"}, {"acLimitW", 8000},
{"baseload", QVariantMap{
{"model", "profile"}, {"p50W", base},
{"quality", QVariantMap{
{"windowDays", 28}, {"minDays", 7}, {"resolutionS", 3600},
{"daysCovered", QVariantMap{{"workday", 14}, {"weekend", 6}}},
{"minutes", 28800}, {"commandedMinutes", 30}, {"negativeMinutes", 0},
{"incompleteMinutes", 12}, {"computedAt", "2026-10-12T03:00:00Z"},
{"loads", QVariantList{
QVariantMap{{"loadId", "ecs"}, {"source", "meter"},
{"minutesBySource", QVariantMap{{"meter", 28000}, {"none", 800}}},
{"constantMeasurement", false}},
QVariantMap{{"loadId", "pac"}, {"source", "none"},
{"minutesBySource", QVariantMap{{"none", 28800}}}}}}}}}},
{"price", QVariantMap{{"currency", "EUR"}, {"importCtkWh", prix}, {"exportCtkWh", prix},
{"includes", TariffConfig::includes()}, {"source", "tou"},
{"knownUntil", "2026-10-12T16:00:00Z"}}},
};
}
//! Tout ce que le sérialiseur sait émettre, une fois au moins.
QVariantMap contexteComplet()
{
SurplusContext ctx;
ctx.timestamp = instant();
ctx.site.contractedPowerW = 12000;
ctx.meter.importW = 0;
ctx.meter.exportW = 2310;
ctx.meter.perPhaseA = {3.2, 4.1, 2.8};
ctx.battery.present = true;
ctx.battery.socPercent = 62;
ctx.battery.powerW = -800;
ctx.battery.capacityWh = 10000;
ctx.battery.maxChargeW = 5000;
ctx.battery.maxDischargeW = 0; // inconnu : null
ctx.tariff.provider = QStringLiteral("tempo");
ctx.tariff.current = {QDateTime(), QStringLiteral("HP_BLEU"), 16.1};
ctx.tariff.next = {{QDateTime(QDate(2026, 10, 13), QTime(6, 0), QTimeZone(7200)), QStringLiteral("ROUGE"), 75.6},
{QDateTime(), QStringLiteral("INCONNU"), 0}};
LoadContext ecs = enWatts(QStringLiteral("ecs"), QStringLiteral("relay-router"), 2);
ecs.needs.dailyDeadline = QStringLiteral("18:00");
ecs.needs.minEnergyWhPerDay = 4000;
ecs.derogation = true;
ecs.derogationTargetWh = 1500.5;
ecs.derogationEndsAt = instant().addSecs(3600);
ecs.learned.dailyEnergyWh = 6400;
ecs.learned.confidence = 0.35;
ctx.loads = {borne(true, true), ecs, enWatts(QStringLiteral("variable"), QStringLiteral("etmvariableload"), 3),
pac(), borne(false, false)};
ContexteFil fil;
fil.trigger = QStringLiteral("load-change");
fil.meterSource = QStringLiteral("compteur-racine");
fil.pvCurrentW = 3400;
fil.phaseLimitA = {63, 63, 63};
fil.reserveSocPercent = 15;
fil.domaines = {{"borne", "ev"}, {"ecs", "ecs"}, {"pac", "heating"}};
fil.mesures.insert(QStringLiteral("borne"), MesureFil{QStringLiteral("device"), 4100});
fil.mesures.insert(QStringLiteral("ecs"), MesureFil{QStringLiteral("meter"), 1580});
fil.mesures.insert(QStringLiteral("pac"), MesureFil{QStringLiteral("none"), 0});
fil.prevision = instantaneComplet();
return getPlanParams(ctx, fil);
}
//! Le minimum : aucun stockage, rien de connu du fil, prévision en faute (pv null, prix null).
QVariantMap contexteMinimal()
{
SurplusContext ctx;
ctx.timestamp = instant().toUTC();
ctx.meter.importW = 1200;
ctx.loads = {enWatts(QStringLiteral("seule"), QStringLiteral("etmvariableload"), 1)};
ContexteFil fil;
QVariantList nuls;
for (int i = 0; i < 3; ++i)
nuls << QVariant();
fil.prevision = QVariantMap{{"start", "2026-10-11T22:00:00Z"}, {"resolutionS", 3600}, {"steps", 3},
{"baseload", QVariantMap{{"model", "none"}, {"p50W", nuls}}},
{"price", QVariant()}, {"fault", "MISSING_FORECAST_SITE"}};
return getPlanParams(ctx, fil);
}
//! Sans instantané du tout (forecast: null) ; borne qui ne compte pas sa session ; charge figée.
QVariantMap contexteSansPrevision()
{
SurplusContext ctx;
ctx.timestamp = instant();
LoadContext fige = enWatts(QStringLiteral("figee"), QStringLiteral("relay-router"), 2);
fige.telemetry.available = false;
fige.telemetry.lockMaxPowerW = 1600;
ctx.loads = {borne(true, false), fige};
return getPlanParams(ctx, ContexteFil());
}
QJsonObject requete(int id, const QVariantMap &params)
{
return QJsonObject{{"jsonrpc", "2.0"}, {"id", id}, {"method", "GetPlan"},
{"params", QJsonObject::fromVariantMap(params)}};
}
} // namespace
class TestProtocolSchema : public QObject
{
Q_OBJECT
private slots:
void testTheEmbeddedContractIsTheDocumentedOne();
void testClosedListsAreGeneratedFromTheCode();
void testEveryCodeTheSourceEmitsIsListed();
void testSemanticRulesAreTheValidatorVerdicts();
void testEverySerializerBranchIsInTheContract();
};
void TestProtocolSchema::testTheEmbeddedContractIsTheDocumentedOne()
{
// Servi par le moteur qui tourne : la ressource Qt, jamais un fichier relu à l'exécution. Elle
// doit être celle de docs/ — une ressource périmée servirait un contrat que personne ne relit.
const QByteArray embarque = schemaDocumentBytes();
QVERIFY2(!embarque.isEmpty(), "ressource du contrat absente du binaire");
QFile f(Contrat::schemaDuDepot());
QVERIFY2(f.open(QIODevice::ReadOnly), qUtf8Printable(f.fileName()));
QVERIFY2(f.readAll() == embarque, "le contrat EMBARQUÉ diffère de docs/ — build à refaire");
const QJsonObject doc = contrat();
QCOMPARE(doc.value("openrpc").toString(), QString("1.3.2"));
QCOMPARE(doc.value("info").toObject().value("version").toString(), QString::fromLatin1(ProtocolVersion));
QCOMPARE(schemaDocument(), doc.toVariantMap());
QStringList methodes;
for (const QJsonValue &m : doc.value("methods").toArray())
methodes << m.toObject().value("name").toString();
QCOMPARE(methodes, (QStringList{"Handshake", "GetPlan", "UpdateLearned"}));
}
void TestProtocolSchema::testClosedListsAreGeneratedFromTheCode()
{
const QJsonObject code = listesDuCode();
// Régénération : la liste du code, écrite là où l'outil la lira (tools/etm-powersync-gen-protocol-doc
// --enums-from). Écrite AVANT la comparaison : c'est quand elles divergent qu'on en a besoin.
const QByteArray sortie = qgetenv("ETM_POWERSYNC_CODE_LISTS");
if (!sortie.isEmpty()) {
QFile f(QString::fromLocal8Bit(sortie));
QVERIFY(f.open(QIODevice::WriteOnly));
f.write(QJsonDocument(code).toJson(QJsonDocument::Indented));
}
const QJsonObject doc = contrat();
const QJsonArray items = doc.value("x-etm-generated").toObject().value("items").toArray();
// L'ancre est ICI, pas dans le contrat : retirer une entrée de x-etm-generated ne fait pas
// disparaître le contrôle, il casse.
QSet<QString> sources;
for (const QJsonValue &i : items)
sources.insert(i.toObject().value("from").toString());
QCOMPARE(liste(sources), liste(ensemble(code.keys())));
QStringList ecarts;
for (const QJsonValue &i : items) {
const QString pointeur = i.toObject().value("pointer").toString();
const QString de = i.toObject().value("from").toString();
const QJsonValue dans = aPointeur(doc, pointeur);
if (dans != code.value(de))
ecarts << QStringLiteral("%1 ← %2\n contrat : %3\n code : %4")
.arg(pointeur, de,
QString::fromUtf8(QJsonDocument(QJsonArray{dans}).toJson(QJsonDocument::Compact)),
QString::fromUtf8(QJsonDocument(QJsonArray{code.value(de)}).toJson(QJsonDocument::Compact)));
}
QVERIFY2(ecarts.isEmpty(),
qUtf8Printable(QStringLiteral("le contrat et le code divergent — régénérer :\n"
" ETM_POWERSYNC_CODE_LISTS=/tmp/listes.json <cette suite> "
"testClosedListsAreGeneratedFromTheCode\n"
" tools/etm-powersync-gen-protocol-doc --enums-from /tmp/listes.json\n")
+ ecarts.join(QLatin1Char('\n'))));
}
void TestProtocolSchema::testEveryCodeTheSourceEmitsIsListed()
{
// Les listes de code ne sont pas le code : un refus ajouté au validateur sans entrée dans la
// liste échapperait au contrat. On lit donc les SOURCES — la seule chose qui subsiste quand la
// liste oublie (AGENTS.md, « le contrôle qui disparaît »).
const QString plan = source(QStringLiteral("energyplugin/etm/scheduler/optimizerplan.cpp"));
const QString client = source(QStringLiteral("energyplugin/etm/scheduler/optimizerclient.cpp"));
const QString socket = source(QStringLiteral("energyplugin/etm/scheduler/socketscheduler.cpp"));
const QString arbitre = source(QStringLiteral("energyplugin/etm/energyarbitrator.cpp"));
QVERIFY(!plan.isEmpty() && !client.isEmpty() && !socket.isEmpty() && !arbitre.isEmpty());
// Verdicts : refus(Issue::X, QStringLiteral("code") …) et les retours de convertirAction().
using OptimizerPlan::Issue;
const QHash<QString, Issue> issues{{"Abstention", Issue::Abstention}, {"Expire", Issue::Expire},
{"Invalide", Issue::Invalide}};
QSet<QString> lus;
QRegularExpressionMatchIterator it =
QRegularExpression(QStringLiteral("refus\\(Issue::(\\w+),\\s*QStringLiteral\\(\"([a-z-]+)\"\\)")).globalMatch(plan);
QStringList mauvaiseIssue;
while (it.hasNext()) {
const QRegularExpressionMatch m = it.next();
lus.insert(m.captured(2));
if (!OptimizerPlan::verdictCodes(issues.value(m.captured(1))).contains(m.captured(2)))
mauvaiseIssue << m.captured(2) + QStringLiteral(" (") + m.captured(1) + QLatin1Char(')');
}
const int debut = plan.indexOf(QStringLiteral("QString convertirAction("));
const int fin = plan.indexOf(QStringLiteral("QString decrire("), debut);
QVERIFY(debut > 0 && fin > debut);
for (const QString &c : captures(plan.mid(debut, fin - debut), QStringLiteral("return QStringLiteral\\(\"([a-z-]+)\"\\);"))) {
lus.insert(c);
if (!OptimizerPlan::verdictCodes(Issue::Invalide).contains(c))
mauvaiseIssue << c + QStringLiteral(" (action : Invalide)");
}
QVERIFY2(lus.size() >= 14, qUtf8Printable(QStringLiteral("lecture des sources suspecte : %1 code(s)").arg(lus.size())));
QCOMPARE(liste(lus), liste(ensemble(OptimizerPlan::verdictCodes())));
QVERIFY2(mauvaiseIssue.isEmpty(), qUtf8Printable(mauvaiseIssue.join(", ")));
// Client : chaque code retenu ou émis.
QSet<QString> erreurs = captures(client, QStringLiteral("(?:failConnection|countProtocolError)\\(QStringLiteral\\(\"([a-z-]+)\"\\)"));
erreurs += captures(client, QStringLiteral("m_lastErrorCode = QStringLiteral\\(\"([a-z-]+)\"\\)"));
erreurs += captures(client, QStringLiteral("planFailed\\([^;]*QStringLiteral\\(\"([a-z-]+)\"\\)"));
QVERIFY2(erreurs.size() >= 15, qUtf8Printable(QStringLiteral("lecture des sources suspecte : %1 code(s)").arg(erreurs.size())));
QCOMPARE(liste(erreurs), liste(ensemble(OptimizerClient::knownErrorCodes())));
// Repli publié : chaque noterCycle(false, "code") du SocketScheduler.
const QSet<QString> replis = captures(socket, QStringLiteral("noterCycle\\(false,\\s*QStringLiteral\\(\"([a-z-]+)\"\\)"));
QCOMPARE(liste(replis), QString("no-plan, no-shadow-slot"));
QVERIFY(ensemble(SocketScheduler::knownFallbackCodes()).contains(replis));
// Dernier code publié par l'arbitre lui-même.
const QSet<QString> publies = captures(arbitre, QStringLiteral("\"lastErrorCode\"\\),\\s*QStringLiteral\\(\"([a-z-]+)\"\\)"));
QCOMPARE(liste(publies), QString("invalid-endpoint"));
QVERIFY(ensemble(SocketScheduler::knownLastErrorCodes()).contains(publies));
}
void TestProtocolSchema::testSemanticRulesAreTheValidatorVerdicts()
{
const QJsonObject doc = contrat();
const QJsonArray regles = doc.value("x-etm-semantic-rules").toObject().value("rules").toArray();
const QStringList replis = SocketScheduler::knownFallbackCodes();
const QStringList erreurs = SocketScheduler::knownLastErrorCodes();
const QHash<QString, QString> issue{{"abstention", "Abstention"}, {"expired", "Expire"}, {"invalid", "Invalide"}};
QStringList noms;
for (const QJsonValue &v : regles) {
const QJsonObject r = v.toObject();
const QString nom = r.value("name").toString();
QVERIFY2(replis.contains(r.value("fallbackCode").toString()), qUtf8Printable(nom));
if (r.contains("lastErrorCode"))
QVERIFY2(erreurs.contains(r.value("lastErrorCode").toString()), qUtf8Printable(nom));
if (r.value("method").toString() != QLatin1String("GetPlan")) {
QCOMPARE(r.value("issue").toString(), QString("connection-refused"));
QVERIFY2(OptimizerClient::knownErrorCodes().contains(nom), qUtf8Printable(nom));
continue;
}
noms << nom;
QCOMPARE(r.value("fallbackCode").toString(), nom);
const OptimizerPlan::Issue attendue = nom == QLatin1String("expired") ? OptimizerPlan::Issue::Expire
: OptimizerPlan::verdictCodes(OptimizerPlan::Issue::Abstention).contains(nom)
? OptimizerPlan::Issue::Abstention : OptimizerPlan::Issue::Invalide;
QVERIFY2(OptimizerPlan::verdictCodes(attendue).contains(nom), qUtf8Printable(nom));
QCOMPARE(issue.value(r.value("issue").toString()),
QString(attendue == OptimizerPlan::Issue::Expire ? "Expire"
: attendue == OptimizerPlan::Issue::Abstention ? "Abstention" : "Invalide"));
QCOMPARE(r.value("countsAsFailure").toBool(), attendue == OptimizerPlan::Issue::Invalide);
}
// Même ensemble, même ORDRE : l'ordre des contrôles décide du code qu'un plan fautif reçoit.
QCOMPARE(noms, OptimizerPlan::verdictCodes());
}
void TestProtocolSchema::testEverySerializerBranchIsInTheContract()
{
// Les requêtes sont FERMÉES dans le contrat (additionalProperties: false) : un champ ajouté au
// sérialiseur sans le contrat fait échouer ce test — sur chaque branche, pas seulement celles
// des fichiers de référence de `loadmodel`.
const QList<QJsonObject> requetes{requete(1, contexteComplet()), requete(2, contexteMinimal()),
requete(3, contexteSansPrevision())};
// Garde de pertinence : les branches visées sont bien PRÉSENTES dans ce qui est jugé.
const QJsonObject complet = requetes.at(0).value("params").toObject();
QVERIFY(complet.value("forecast").toObject().value("pv").toObject().contains("perField"));
QVERIFY(complet.value("forecast").toObject().value("price").isObject());
QVERIFY(complet.value("tariff").isObject());
QVERIFY(complet.value("battery").toObject().value("present").toBool());
QCOMPARE(complet.value("loads").toArray().size(), 5);
QVERIFY(requetes.at(1).value("params").toObject().value("forecast").toObject().value("pv").isNull());
QVERIFY(requetes.at(2).value("params").toObject().value("forecast").isNull());
QTemporaryDir dir;
QVERIFY(dir.isValid());
const Contrat::Verdict v = Contrat::juger(requetes, Contrat::ecrireSchema(dir, schemaDocumentBytes()),
{QStringLiteral("--requests-only")});
QVERIFY2(v.code == 0, qUtf8Printable(v.sortie));
QCOMPARE(v.compte("GetPlan.params"), 3);
}
QTEST_GUILESS_MAIN(TestProtocolSchema)
#include "testprotocolschema.moc"