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>
481 lines
22 KiB
C++
481 lines
22 KiB
C++
// 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"
|