diff --git a/tests/auto/auto.pro b/tests/auto/auto.pro index 2f92ee8..df00860 100644 --- a/tests/auto/auto.pro +++ b/tests/auto/auto.pro @@ -1,2 +1,2 @@ TEMPLATE = subdirs -SUBDIRS += charging loadmodel simulation spotmarket +SUBDIRS += charging decisions loadmodel simulation spotmarket diff --git a/tests/auto/decisions/decisionrecorder.cpp b/tests/auto/decisions/decisionrecorder.cpp new file mode 100644 index 0000000..17f93cd --- /dev/null +++ b/tests/auto/decisions/decisionrecorder.cpp @@ -0,0 +1,398 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync + +/* Décisions de référence (brief 4 T0.5). + * + * Chaque cycle que les scénarios de `simulation` font tourner passe par ici (voir le `--wrap` de + * decisions.pro) : le VRAI cycle s'exécute, puis on sérialise une ligne NDJSON dans le fichier + * nommé par ETM_DECISIONS_OUT. Sans cette variable, rien n'est écrit et la suite tourne comme + * `simulation`. + * + * Ce qu'une ligne porte, et pourquoi c'est la décision : + * - `telemetry` : l'instantané que l'arbitre vient de bâtir (loadTelemetry()). Ses `loads[]` + * sont les actions du créneau, DANS L'ORDRE du créneau et après fusion du délestage : + * motif {code, params, level}, allocatedW (= estimatedPowerW), niveaux (targetW, funding, + * counts, motif, avancement), plus ce que l'adaptateur a réellement posé (mechanism, lock, + * command.expectedW) ; `budget` et `draw` du plan ; `degradedMode` ; + * - `proxy` : les ChargingAction que le plan a laissées au proxy amont (scheduledActions()) ; + * - `things` : l'état de chaque Thing après le cycle — c'est là que finit une consigne + * (courant, phases, relais, setpoint, état SG-Ready), qu'elle vienne du waterfall ou du proxy. + * + * Une partie des scénarios n'a pas d'arbitre : ils construisent un SurplusContext et + * interrogent un RuleBasedScheduler local. Ces appels-là sont interceptés aussi (second `--wrap`) + * et leur Plan est sérialisé EN ENTIER, champ par champ (kind, powerW, état, courant, phases, + * niveaux, avancement, force…). Un getPlan() fait pendant un cycle n'est pas réécrit : le cycle + * le décrit déjà. + * + * Ce qui est EXCLU, et seulement cela : + * - les horodatages muraux : `command.since` (ancré sur QDateTime::currentDateTime()) et les + * compteurs `totalEnergy*` des Things, que le plugin mock intègre sur l'horloge murale ; + * - toute identité tirée au hasard par exécution : les ThingId deviennent `@1`, `@2`… dans + * l'ordre de première apparition dans la télémétrie ou le plan de LA LIGNE ; `planId` (Plan + * direct) n'est pas sérialisé, et l'arbitre ne le publie pas. + * Deux exécutions du même code doivent donner le même fichier à l'octet près : c'est ainsi que + * cette liste a été établie (2026-10-16), et c'est ainsi qu'on vérifiera un ajout. + * + * Le contrôle ne doit pas pouvoir disparaître en silence (AGENTS.md) : un cycle sur un + * gestionnaire qui n'est pas un EnergyArbitrator s'écrit `"arbitre": false`, et le lanceur + * refuse un fichier où aucun cycle n'a été enregistré. + */ + +#include +#include +#include +#include +#include + +#include +#include + +#include "../../../energyplugin/smartchargingmanager.h" +#include "../../../energyplugin/evcharger.h" +#include "../../../energyplugin/etm/energyarbitrator.h" + +#include "../../../energyplugin/etm/scheduler/rulebasedscheduler.h" + +#define REEL_CYCLE __real__ZN20SmartChargingManager20simulationCallUpdateERK9QDateTime +#define ENVEL_CYCLE __wrap__ZN20SmartChargingManager20simulationCallUpdateERK9QDateTime +#define REEL_PLAN __real__ZN18RuleBasedScheduler7getPlanERK14SurplusContext +#define ENVEL_PLAN __wrap__ZN18RuleBasedScheduler7getPlanERK14SurplusContext + +// extern "C" ne change que le nom, pas la convention : `this` passe en premier argument, et le +// Plan rendu par valeur passe par le pointeur de retour caché, comme pour la fonction membre. +extern "C" void REEL_CYCLE(SmartChargingManager *self, const QDateTime &dateTime); +extern "C" Plan REEL_PLAN(RuleBasedScheduler *self, const SurplusContext &ctx); + +namespace { + +class Enregistreur +{ +public: + static Enregistreur &instance() { static Enregistreur e; return e; } + + //! Profondeur de cycle : un getPlan() appelé PENDANT un cycle appartient au cycle, et il est + //! déjà décrit par sa télémétrie. Seuls les appels directs des scénarios sont enregistrés + //! comme plans — ce qui rend l'enregistrement indépendant de la façon dont l'arbitre + //! atteint son planificateur (appel direct ou virtuel). + int dansCycle = 0; + + void noterCycle(SmartChargingManager *scm, const QDateTime &dateTime) + { + if (!m_fichier.isOpen()) + return; + QVariantMap ligne = entete(QStringLiteral("cycle")); + ligne.insert(QStringLiteral("t"), dateTime.toUTC().toString(Qt::ISODateWithMs)); + + // L'ORDRE de traitement fixe l'ordre des alias : les charges du créneau d'abord, dans + // l'ordre du créneau, puis le proxy, puis les Things. + EnergyArbitrator *arb = dynamic_cast(scm); + ligne.insert(QStringLiteral("arbitre"), arb != nullptr); + if (arb) { + ligne.insert(QStringLiteral("telemetry"), anonymiser(sansMural(arb->loadTelemetry()))); + ligne.insert(QStringLiteral("proxy"), trieParRendu(anonymiser(proxy(arb), false))); + } + ligne.insert(QStringLiteral("things"), trieParRendu(anonymiser(things(), false))); + ecrire(ligne); + } + + void noterPlan(const SurplusContext &ctx, const Plan &plan) + { + if (!m_fichier.isOpen() || dansCycle > 0) + return; + QVariantMap ligne = entete(QStringLiteral("plan")); + ligne.insert(QStringLiteral("t"), ctx.timestamp.toUTC().toString(Qt::ISODateWithMs)); + ligne.insert(QStringLiteral("plan"), anonymiser(versCarte(plan))); + ecrire(ligne); + } + +private: + QVariantMap entete(const QString &source) + { + const QString fn = QString::fromLatin1(QTest::currentTestFunction()); + const QString tag = QString::fromLatin1(QTest::currentDataTag()); + if (fn != m_fn) { + m_fn = fn; + m_n = 0; + } + // Les alias sont propres à LA LIGNE : une charge y est nommée par son rang d'apparition + // dans le créneau. Une identité suivie d'un cycle à l'autre ne tient pas : deux bornes + // identiques reçoivent leur rang provisionné dans l'ordre d'un QHash, si bien que + // LAQUELLE charge change d'une exécution à l'autre, pour des décisions symétriques + // (constaté le 2026-10-16 sur testEvChargerRankDecidesWhoCharges). + m_alias.clear(); + QVariantMap ligne; + ligne.insert(QStringLiteral("fn"), fn); + if (!tag.isEmpty()) + ligne.insert(QStringLiteral("tag"), tag); + ligne.insert(QStringLiteral("n"), m_n++); + ligne.insert(QStringLiteral("src"), source); + return ligne; + } + + void ecrire(const QVariantMap &ligne) + { + m_fichier.write(QJsonDocument::fromVariant(ligne).toJson(QJsonDocument::Compact)); + m_fichier.write("\n"); + m_fichier.flush(); + } + + static QString date(const QDateTime &d) + { + return d.isValid() ? d.toUTC().toString(Qt::ISODateWithMs) : QString(); + } + + static QVariantMap comptes(const QMap &c) + { + QVariantMap r; + for (auto it = c.constBegin(); it != c.constEnd(); ++it) + r.insert(it.key(), it.value()); + return r; + } + + //! Le Plan EN ENTIER, champ par champ — sauf `planId`, identité tirée au hasard. + static QVariantMap versCarte(const Plan &plan) + { + QVariantList creneaux; + for (const Slot &s : plan.timeSlots) { + QVariantList actions; + for (const LoadAction &a : s.actions) { + QVariantList niveaux; + for (const LoadAction::Level &n : a.levels) { + QVariantMap niveau{ + {QStringLiteral("level"), n.level}, + {QStringLiteral("targetW"), n.targetW}, + {QStringLiteral("funding"), int(n.funding)}, + {QStringLiteral("counts"), comptes(n.counts)}, + {QStringLiteral("reason"), n.reason.toMap()}, + {QStringLiteral("hasProgress"), n.hasProgress}}; + if (n.hasProgress) { + const EcoProgress &p = n.progress; + niveau.insert(QStringLiteral("progress"), QVariantMap{ + {QStringLiteral("regime"), p.regime}, + {QStringLiteral("delivered"), p.delivered}, + {QStringLiteral("deliveredWh"), p.deliveredWh}, + {QStringLiteral("targetWh"), p.targetWh}, + {QStringLiteral("purchasedWh"), p.purchasedWh}, + {QStringLiteral("measuredSince"), date(p.measuredSince)}, + {QStringLiteral("measuredGaps"), p.measuredGaps}, + {QStringLiteral("closedMissed"), p.closedMissed}, + {QStringLiteral("closedMissingWh"), p.closedMissingWh}, + {QStringLiteral("closedKnown"), p.closedKnown}, + {QStringLiteral("closedCause"), p.closedCause}}); + } + niveaux.append(niveau); + } + actions.append(QVariantMap{ + {QStringLiteral("loadId"), a.loadId}, + {QStringLiteral("kind"), int(a.kind)}, + {QStringLiteral("funding"), int(a.funding)}, + {QStringLiteral("chargingEnabled"), a.chargingEnabled}, + {QStringLiteral("currentA"), a.currentA}, + {QStringLiteral("phaseCount"), a.phaseCount}, + {QStringLiteral("powerW"), a.powerW}, + {QStringLiteral("source"), int(a.source)}, + {QStringLiteral("state"), a.state}, + {QStringLiteral("charge"), int(a.charge)}, + {QStringLiteral("discharge"), int(a.discharge)}, + {QStringLiteral("reason"), a.reason.toMap()}, + {QStringLiteral("estimatedPowerW"), a.estimatedPowerW}, + {QStringLiteral("counts"), comptes(a.counts)}, + {QStringLiteral("levels"), niveaux}, + {QStringLiteral("force"), a.force}}); + } + creneaux.append(QVariantMap{{QStringLiteral("from"), date(s.from)}, + {QStringLiteral("to"), date(s.to)}, + {QStringLiteral("actions"), actions}}); + } + QStringList bornes(plan.waterfallEvIds.constBegin(), plan.waterfallEvIds.constEnd()); + bornes.sort(); + return QVariantMap{ + {QStringLiteral("strategy"), plan.strategy}, + {QStringLiteral("timeSlots"), creneaux}, + {QStringLiteral("budget"), QVariantMap{ + {QStringLiteral("surplusW"), plan.budget.surplusW}, + {QStringLiteral("evReservedW"), plan.budget.evReservedW}, + {QStringLiteral("allocatedW"), plan.budget.allocatedW}, + {QStringLiteral("recreditedW"), plan.budget.recreditedW}, + {QStringLiteral("remainingW"), plan.budget.remainingW}}}, + {QStringLiteral("draw"), QVariantMap{ + {QStringLiteral("committedW"), plan.draw.committedW}, + {QStringLiteral("capBorne"), plan.draw.capBorne}, + {QStringLiteral("authorisedW"), plan.draw.authorisedW}, + {QStringLiteral("remainingW"), plan.draw.remainingW}, + {QStringLiteral("bindingCode"), plan.draw.bindingCode}, + {QStringLiteral("bindingPhase"), plan.draw.bindingPhase}, + {QStringLiteral("perPhaseBound"), plan.draw.perPhaseBound}}}, + {QStringLiteral("waterfallEvIds"), bornes}}; + } + +private: + Enregistreur() + { + const QString chemin = QString::fromLocal8Bit(qgetenv("ETM_DECISIONS_OUT")); + if (chemin.isEmpty()) + return; + m_fichier.setFileName(chemin); + if (!m_fichier.open(QIODevice::WriteOnly | QIODevice::Append)) + qFatal("decisionrecorder : impossible d'ouvrir %s", qPrintable(chemin)); + } + + //! Retire les horodatages muraux. La liste est FERMÉE : un champ mural nouveau fera + //! diverger deux exécutions du même code, et c'est le lanceur (`stability`) qui le dira. + static QVariant sansMural(const QVariant &v) + { + if (v.typeId() == QMetaType::QVariantMap) { + QVariantMap m = v.toMap(); + if (m.contains(QStringLiteral("command"))) { + QVariantMap c = m.value(QStringLiteral("command")).toMap(); + c.remove(QStringLiteral("since")); + m.insert(QStringLiteral("command"), c); + } + for (auto it = m.begin(); it != m.end(); ++it) + it.value() = sansMural(it.value()); + return m; + } + if (v.typeId() == QMetaType::QVariantList) { + QVariantList l = v.toList(); + for (QVariant &e : l) + e = sansMural(e); + return l; + } + return v; + } + + //! N'ATTRIBUENT un alias que la télémétrie et le plan, dont l'ordre (celui du créneau) est + //! déterministe. Le proxy et les Things sont rangés par QHash : deux bornes homonymes y + //! apparaissent dans un ordre tiré au hasard, et un alias attribué là changerait d'une + //! exécution à l'autre. Un ThingId qu'ils sont seuls à citer s'écrit donc `@?`. + QString alias(const QString &s, bool attribuer) + { + static const QRegularExpression uuid( + QStringLiteral("\\{?[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\\}?")); + QString r; + qsizetype dernier = 0; + auto it = uuid.globalMatch(s); + while (it.hasNext()) { + const QRegularExpressionMatch m = it.next(); + QString cle = m.captured(0).toLower(); + cle.remove(QLatin1Char('{')).remove(QLatin1Char('}')); + if (attribuer && !m_alias.contains(cle)) + m_alias.insert(cle, QStringLiteral("@%1").arg(m_alias.size() + 1)); + r += s.mid(dernier, m.capturedStart(0) - dernier) + + m_alias.value(cle, QStringLiteral("@?")); + dernier = m.capturedEnd(0); + } + return dernier == 0 ? s : r + s.mid(dernier); + } + + QVariant anonymiser(const QVariant &v, bool attribuer = true) + { + switch (v.typeId()) { + case QMetaType::QVariantMap: { + // Valeurs d'abord, dans l'ordre des clés d'origine — puis les clés elles-mêmes. + const QVariantMap m = v.toMap(); + QList> paires; + for (auto it = m.constBegin(); it != m.constEnd(); ++it) + paires.append({it.key(), anonymiser(it.value(), attribuer)}); + QVariantMap r; + for (const auto &p : paires) + r.insert(alias(p.first, attribuer), p.second); + return r; + } + case QMetaType::QVariantList: { + QVariantList l = v.toList(); + for (QVariant &e : l) + e = anonymiser(e, attribuer); + return l; + } + case QMetaType::QString: + case QMetaType::QUuid: + case QMetaType::QByteArray: + return alias(v.toString(), attribuer); + default: + return v; + } + } + + //! Range une liste par son rendu JSON, APRÈS anonymisation : l'ordre d'un QHash ne doit + //! rien décider. + static QVariant trieParRendu(const QVariant &v) + { + QList> liste; + for (const QVariant &e : v.toList()) + liste.append({QJsonDocument::fromVariant(e).toJson(QJsonDocument::Compact), e}); + std::sort(liste.begin(), liste.end(), + [](const auto &a, const auto &b) { return a.first < b.first; }); + QVariantList r; + for (const auto &p : liste) + r.append(p.second); + return r; + } + + static QVariant proxy(EnergyArbitrator *arb) + { + QVariantList bornes; + const auto &actions = arb->scheduledActions(); + for (auto it = actions.constBegin(); it != actions.constEnd(); ++it) { + QVariantMap parEmetteur; + for (auto a = it.value().constBegin(); a != it.value().constEnd(); ++a) { + parEmetteur.insert(ChargingAction::issuerToString(a.key()), QVariantMap{ + {QStringLiteral("enabled"), QVariant(a.value().chargingEnabled())}, + {QStringLiteral("currentA"), QVariant(a.value().maxChargingCurrent())}, + {QStringLiteral("phases"), QVariant(a.value().desiredPhaseCount())}, + {QStringLiteral("force"), QVariant(a.value().force())}}); + } + Thing *t = it.key()->thing(); + QVariantMap borne{{QStringLiteral("thing"), t ? t->id().toString() : QString()}, + {QStringLiteral("actions"), parEmetteur}}; + borne.insert(QStringLiteral("name"), t ? t->name() : QString()); + bornes.append(borne); + } + return bornes; + } + + static QVariant things() + { + // Les compteurs d'énergie CUMULÉS sont exclus : le plugin mock les intègre sur l'horloge + // MURALE (mesuré le 2026-10-16 : `totalEnergyConsumed` diffère entre deux exécutions du + // même code). Ce sont des entrées de l'arbitre, pas des décisions ; ce qu'il en tire + // (avancement éco) est publié par la télémétrie, qui reste comparée. + QVariantList liste; + for (Thing *t : nymeaserver::NymeaCore::instance()->thingManager()->configuredThings()) { + QVariantMap etats; + for (const State &s : t->states()) { + const StateType st = t->thingClass().stateTypes().findById(s.stateTypeId()); + if (st.name().startsWith(QLatin1String("totalEnergy"))) + continue; + etats.insert(st.name(), s.value()); + } + liste.append(QVariantMap{{QStringLiteral("class"), t->thingClass().name()}, + {QStringLiteral("name"), t->name()}, + {QStringLiteral("states"), etats}}); + } + return liste; + } + + QFile m_fichier; + QString m_fn; + int m_n = 0; + QHash m_alias; +}; + +} // namespace + +extern "C" void ENVEL_CYCLE(SmartChargingManager *self, const QDateTime &dateTime) +{ + Enregistreur &e = Enregistreur::instance(); + ++e.dansCycle; + REEL_CYCLE(self, dateTime); + --e.dansCycle; + e.noterCycle(self, dateTime); +} + +extern "C" Plan ENVEL_PLAN(RuleBasedScheduler *self, const SurplusContext &ctx) +{ + Plan plan = REEL_PLAN(self, ctx); + Enregistreur::instance().noterPlan(ctx, plan); + return plan; +} diff --git a/tests/auto/decisions/decisions.pro b/tests/auto/decisions/decisions.pro new file mode 100644 index 0000000..11855f4 --- /dev/null +++ b/tests/auto/decisions/decisions.pro @@ -0,0 +1,50 @@ +# Décisions de référence (brief 4 T0.5) — la preuve « sans endpoint = identique au bit près ». +# +# Ce binaire est la suite `simulation`, compilée À L'IDENTIQUE (mêmes sources, même +# ENERGY_SIMULATION), plus un enregistreur. Aucune ligne de la suite n'est modifiée : le point +# d'écoute est posé à l'ÉDITION DE LIENS. `--wrap` redirige chaque appel de +# SmartChargingManager::simulationCallUpdate() fait par les scénarios vers +# decisionrecorder.cpp, qui appelle le vrai cycle puis sérialise ce qu'il a décidé. +# +# Lancer : tests/auto/decisions/run-decisions.sh (voir son en-tête). + +include(../common/common.pri) +include(../../../energyplugin/energyplugin.pri) +include(../../mocks/spotmarketprovider/spotmarketdataprovidermock.pri) + +CONFIG += testcase + +TARGET = nymea-energy-decisions + +DEFINES += ENERGY_SIMULATION + +SIM = $$PWD/../simulation +INCLUDEPATH += $$SIM + +RESOURCES += \ + $$SIM/simulationresources.qrc + +HEADERS += \ + $$SIM/experience/energyexperienceenergymock.h \ + $$SIM/experience/energyjsonhandler.h \ + $$SIM/experience/energylogger.h \ + $$SIM/experience/energymanagermock.h \ + $$SIM/simulation.h \ + $$SIM/simulationbase.h \ + $$SIM/simulationtestpoint.h + +SOURCES += \ + $$SIM/experience/energyexperienceenergymock.cpp \ + $$SIM/experience/energyjsonhandler.cpp \ + $$SIM/experience/energylogger.cpp \ + $$SIM/experience/energymanagermock.cpp \ + $$SIM/simulation.cpp \ + $$SIM/simulationbase.cpp \ + $$SIM/simulationtestpoint.cpp \ + decisionrecorder.cpp + +# Noms mangés Itanium : SmartChargingManager::simulationCallUpdate(const QDateTime &) (chaque +# cycle des scénarios) et RuleBasedScheduler::getPlan(const SurplusContext &) (les scénarios qui +# interrogent le planificateur directement, sans arbitre). +QMAKE_LFLAGS += -Wl,--wrap=_ZN20SmartChargingManager20simulationCallUpdateERK9QDateTime +QMAKE_LFLAGS += -Wl,--wrap=_ZN18RuleBasedScheduler7getPlanERK14SurplusContext diff --git a/tests/auto/decisions/reference/decisions.ndjson.xz b/tests/auto/decisions/reference/decisions.ndjson.xz new file mode 100644 index 0000000..867d282 Binary files /dev/null and b/tests/auto/decisions/reference/decisions.ndjson.xz differ diff --git a/tests/auto/decisions/run-decisions.sh b/tests/auto/decisions/run-decisions.sh new file mode 100755 index 0000000..23fe63d --- /dev/null +++ b/tests/auto/decisions/run-decisions.sh @@ -0,0 +1,90 @@ +#!/bin/bash +# Décisions de référence (brief 4 T0.5) — rejoue les scénarios de `simulation` et compare, cycle +# par cycle, ce que l'arbitre a décidé au fichier de référence commité. +# +# run-decisions.sh record enregistre seulement +# run-decisions.sh check enregistre puis compare à la référence +# (code 1 au premier écart, et il est montré) +# +# : arbre construit depuis le .pro racine avec CONFIG+=build_tests (le binaire +# `nymea-energy-decisions` y est sous tests/auto/decisions/). +# +# AGENTS.md : UN PROCESSUS PAR TEST, chacun sous MemoryMax=2G. Les tests ne tournent jamais en +# parallèle (le harnais nymea ouvre des ports fixes). Un test qui sort en erreur est rejoué UNE +# fois — instabilité connue du port 4847 — et le rejeu est annoncé, jamais masqué. +set -u + +ICI="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REFERENCE="$ICI/reference/decisions.ndjson.xz" + +BUILD="$(cd "${1:?build}" && pwd)" +MODE="${2:?record|check}" +BIN_DIR="$BUILD/tests/auto/decisions" +BIN="$BIN_DIR/nymea-energy-decisions" +[ -x "$BIN" ] || { echo "binaire absent : $BIN" >&2; exit 2; } + +case "$MODE" in + record) SORTIE="${3:?sortie}" ;; + check) SORTIE="$(mktemp --suffix=.ndjson)" ;; + *) echo "mode inconnu : $MODE" >&2; exit 2 ;; +esac + +TRAVAIL="$(mktemp -d)" +trap 'rm -rf "$TRAVAIL"' EXIT + +export NYMEA_PLUGINS_PATH="$BUILD/tests/mocks/plugins/energymocks:/usr/lib/x86_64-linux-gnu/nymea/plugins" + +# UN /tmp PRIVÉ ET VIDE PAR PROCESSUS. libnymea range l'état du serveur de test sous +# `/tmp/nymea-test`, en dur : configuration des charges, energy.conf, magasin de prévision. Ce +# répertoire est partagé par toutes les exécutions de la machine et il s'accumule — mesuré le +# 2026-10-16, un energy-load-configuration.json de 348 bornes orphelines laissé là le 14. Sans +# isolation, le résultat dépendrait de l'historique de la machine, pas du code. +lancer() { # $1 = fonction, $2 = fichier NDJSON du test + rm -f "$2" + ( cd "$BIN_DIR" && ETM_DECISIONS_OUT="$2" \ + systemd-run --user --scope -q -p MemoryMax=2G -p MemorySwapMax=0 \ + bwrap --dev-bind / / --tmpfs /tmp --bind "$BUILD" "$BUILD" --bind "$TRAVAIL" "$TRAVAIL" \ + --chdir "$BIN_DIR" \ + ./target_wrapper.sh ./nymea-energy-decisions "$1" > "$TRAVAIL/$1.log" 2>&1 ) +} + +mapfile -t FONCTIONS < <(cd "$BIN_DIR" && ./nymea-energy-decisions -functions 2>/dev/null \ + | sed -n 's/^\([A-Za-z_][A-Za-z0-9_]*\)()$/\1/p') +[ "${#FONCTIONS[@]}" -gt 0 ] || { echo "aucune fonction listée" >&2; exit 2; } + +: > "$SORTIE" +total=0 +for f in "${FONCTIONS[@]}"; do + out="$TRAVAIL/$f.ndjson" + lancer "$f" "$out"; code=$? + if [ "$code" -ne 0 ]; then + echo "REJEU $f (code $code) — $(grep -m1 -E 'FAIL!' "$TRAVAIL/$f.log" | cut -c1-160)" >&2 + lancer "$f" "$out"; code=$? + fi + n=0; [ -f "$out" ] && { n=$(wc -l < "$out"); cat "$out" >> "$SORTIE"; } + total=$((total + n)) + resume=$(grep -m1 '^Totals:' "$TRAVAIL/$f.log") + printf '{"fn":"%s","fin":true,"exit":%d,"cycles":%d}\n' "$f" "$code" "$n" >> "$SORTIE" + printf '%-48s exit=%d cycles=%-6d %s\n' "$f" "$code" "$n" "$resume" >&2 +done + +# Le contrôle ne doit pas pouvoir disparaître : un enregistreur débranché (wrap perdu, binaire +# reconstruit sans lui) produirait un fichier sans cycle, que la comparaison verrait comme un +# écart massif — mais on le dit ici en clair. +[ "$total" -gt 0 ] || { echo "AUCUN cycle enregistré — l'enregistreur n'est pas branché" >&2; exit 2; } +echo "fonctions=${#FONCTIONS[@]} cycles=$total" >&2 + +[ "$MODE" = record ] && exit 0 + +ATTENDU="$TRAVAIL/attendu.ndjson" +xz -dc "$REFERENCE" > "$ATTENDU" || exit 2 +if cmp -s "$ATTENDU" "$SORTIE"; then + echo "IDENTIQUE à la référence ($(wc -l < "$ATTENDU") lignes)" >&2 + rm -f "$SORTIE" + exit 0 +fi +echo "ÉCART avec la référence — premières lignes divergentes (- attendu, + obtenu) :" >&2 +diff "$ATTENDU" "$SORTIE" | head -c 6000 >&2 +echo >&2 +echo "fichier obtenu conservé : $SORTIE" >&2 +exit 1