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:
parent
053263bc38
commit
d95a65cb71
@ -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
|
||||
|
||||
102
tests/auto/common/etm-powersync-contrat.h
Normal file
102
tests/auto/common/etm-powersync-contrat.h
Normal 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;
|
||||
}
|
||||
|
||||
}
|
||||
22
tests/auto/protocolschema/protocolschema.pro
Normal file
22
tests/auto/protocolschema/protocolschema.pro
Normal 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
|
||||
480
tests/auto/protocolschema/testprotocolschema.cpp
Normal file
480
tests/auto/protocolschema/testprotocolschema.cpp
Normal 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 ¶ms)
|
||||
{
|
||||
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"
|
||||
Loading…
x
Reference in New Issue
Block a user