test(décisions): référence au bit près des scénarios de simulation (brief 4 T0.5)

Preuve « sans endpoint = identique au bit près », posée AVANT tout refactoring.

`nymea-energy-decisions` est la suite `simulation` compilée à l'identique, plus un
enregistreur branché à l'édition de liens (`--wrap`) : aucune ligne de la suite ni du
moteur n'est modifiée. Chaque cycle (`simulationCallUpdate`) écrit une ligne NDJSON :
télémétrie de l'arbitre (actions du créneau dans l'ordre, motifs, niveaux, budget, soutirage,
consigne posée), actions laissées au proxy, état des Things. Les scénarios qui interrogent
un `RuleBasedScheduler` local voient leur `Plan` sérialisé champ par champ.

Exclus : `command.since` et les compteurs `totalEnergy*` du mock (horloge murale), les
ThingId (alias par rang dans le créneau, à chaque ligne : deux bornes identiques
reçoivent leur rang provisionné dans l'ordre d'un QHash). `planId` n'est pas lu.

Référence générée sur 83483cc (1.15.2+etm69) : 66 fonctions, 23 673 cycles + 36 plans
directs, deux exécutions identiques à l'octet. Chaque processus a un /tmp privé : libnymea
range l'état de test sous /tmp/nymea-test, partagé et cumulatif (348 bornes orphelines
trouvées dans energy-load-configuration.json).

  tests/auto/decisions/run-decisions.sh <build> check

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-10-16 07:24:00 +02:00
parent 83483ccf71
commit d4b7d4dced
5 changed files with 539 additions and 1 deletions

View File

@ -1,2 +1,2 @@
TEMPLATE = subdirs
SUBDIRS += charging loadmodel simulation spotmarket
SUBDIRS += charging decisions loadmodel simulation spotmarket

View File

@ -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 <QtTest>
#include <QFile>
#include <QJsonDocument>
#include <QJsonObject>
#include <QRegularExpression>
#include <nymeacore.h>
#include <integrations/thingmanager.h>
#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<EnergyArbitrator *>(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<QString, double> &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<QPair<QString, QVariant>> 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<QPair<QByteArray, QVariant>> 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<QString, QString> 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;
}

View File

@ -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

Binary file not shown.

View File

@ -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 <build> record <sortie.ndjson> enregistre seulement
# run-decisions.sh <build> check enregistre puis compare à la référence
# (code 1 au premier écart, et il est montré)
#
# <build> : 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