From d95a65cb714ee6c8a83787904e174b09104e8dde Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Sat, 17 Oct 2026 21:30:44 +0200 Subject: [PATCH] test: suite etm-powersync-protocolschema (contrat == docs == code) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- tests/auto/auto.pro | 2 +- tests/auto/common/etm-powersync-contrat.h | 102 ++++ tests/auto/protocolschema/protocolschema.pro | 22 + .../protocolschema/testprotocolschema.cpp | 480 ++++++++++++++++++ 4 files changed, 605 insertions(+), 1 deletion(-) create mode 100644 tests/auto/common/etm-powersync-contrat.h create mode 100644 tests/auto/protocolschema/protocolschema.pro create mode 100644 tests/auto/protocolschema/testprotocolschema.cpp diff --git a/tests/auto/auto.pro b/tests/auto/auto.pro index 6d2061f..9f9bb53 100644 --- a/tests/auto/auto.pro +++ b/tests/auto/auto.pro @@ -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 diff --git a/tests/auto/common/etm-powersync-contrat.h b/tests/auto/common/etm-powersync-contrat.h new file mode 100644 index 0000000..9087bc3 --- /dev/null +++ b/tests/auto/common/etm-powersync-contrat.h @@ -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 +#include +#include +#include +#include +#include +#include + +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 &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 deMethode(const QList &messages, const QString &methode) +{ + QList out; + for (const QJsonObject &m : messages) + if (m.value(QStringLiteral("method")).toString() == methode) + out << m; + return out; +} + +} diff --git a/tests/auto/protocolschema/protocolschema.pro b/tests/auto/protocolschema/protocolschema.pro new file mode 100644 index 0000000..53e2dcc --- /dev/null +++ b/tests/auto/protocolschema/protocolschema.pro @@ -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 diff --git a/tests/auto/protocolschema/testprotocolschema.cpp b/tests/auto/protocolschema/testprotocolschema.cpp new file mode 100644 index 0000000..7f4af36 --- /dev/null +++ b/tests/auto/protocolschema/testprotocolschema.cpp @@ -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 +#include +#include +#include +#include +#include +#include +#include +#include + +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 captures(const QString &texte, const QString &motif, int groupe = 1) +{ + QSet out; + QRegularExpressionMatchIterator it = QRegularExpression(motif).globalMatch(texte); + while (it.hasNext()) + out.insert(it.next().captured(groupe)); + return out; +} + +QSet ensemble(const QStringList &l) +{ + return QSet(l.cbegin(), l.cend()); +} + +QString liste(const QSet &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 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 " + "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 issues{{"Abstention", Issue::Abstention}, {"Expire", Issue::Expire}, + {"Invalide", Issue::Invalide}}; + QSet 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 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 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 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 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 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"