test(mock): le relais sec, et le cas que le banc ne savait pas produire

La classe `dryRelay` commute et ne mesure pas. Elle ouvre le cas que
l'inventaire à froid avait identifié comme structurellement introuvable : une
charge SAINE et NON MESURÉE. Jusqu'ici `measurement.source: none` n'était
atteignable qu'avec un Thing manquant — ce qui traîne `available: false` et
mélange deux affirmations distinctes, « rien ne mesure cette charge » et
« cette charge est en panne ». Un relais sec est banal sur le terrain, et
parfaitement sain.

Le test vérifie ce que rien ne pouvait vérifier avant : deux charges, même
source: none, available opposés, faultCode sur la seule en défaut. Il
n'aurait pas pu s'écrire hier.

Et l'ajout a révélé que le mock avait SA PROPRE prémisse fausse :
executeAction() déréférençait le contrôleur HTTP de chaque Thing sans
vérifier, supposant que toute classe en possède un. Un relais sec n'en a pas
besoin. Le plantage fut immédiat et net ; l'hypothèse, elle, était invisible
tant qu'aucune classe ne la contredisait — l'homogénéité du mock la
protégeait, exactement comme elle masquait les cas qu'il ne savait pas
produire. Ajouter de la diversité au banc n'a pas seulement ouvert un test :
ça a trouvé un défaut.

AGENTS.md reçoit la règle générale sur les identités : une identité dont les
deux termes viennent de la même source ne vérifie rien. Elle est vraie par
construction et rassure là où rien ne protège — la question n'est pas
« tient-elle ? » mais « de quoi parle-t-elle vraiment ? ». Et c'est le même
défaut que la contre-épreuve qui ne mord pas, vu de l'autre côté : perturber
une grandeur qui ne pouvait rien changer, et vérifier une identité qui se
referme sur elle-même, sont deux gestes de vérification qui ne touchent rien.
Le réflexe commun : demander ce que ce contrôle aurait attrapé s'il avait
échoué. S'il n'y a pas de réponse, il ne contrôle rien.

Suite : simulation 62/62, loadmodel 22/22, charging 48/48 identique à la
référence, spotmarket 32/32, doxygen 0 avertissement.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
This commit is contained in:
Patrick Schurig 2026-09-02 08:00:51 +02:00
parent ccf05cea87
commit cdd3bc88f3
9 changed files with 231 additions and 5 deletions

View File

@ -911,6 +911,18 @@ aucune, la décision est une intention, et elle se périmera à la vitesse du co
> d'elle-même. Celle-là décrit ce que le moteur a décidé, pas ce que la maison fait — et il a
> fallu un champ HORS de la formule pour relier les deux. La deuxième reste ouverte, et il faut
> le savoir plutôt que de croire qu'elle prouve quelque chose sur l'installation.
>
> **La règle générale, à poser AVANT de se servir d'une identité comme garde :**
> **une identité dont les deux termes viennent de la MÊME source ne vérifie rien.** Elle est
> vraie par construction — et elle rassure exactement là où rien ne protège. La question n'est
> donc pas « tient-elle ? » mais **« de quoi parle-t-elle vraiment ? »**.
>
> **Et c'est le même défaut que la contre-épreuve qui ne mord pas, vu de l'autre côté.** Perturber
> une grandeur qui ne pouvait pas changer le résultat, et vérifier une identité qui se referme sur
> elle-même, sont deux façons de faire un geste de vérification **qui ne touche rien**. Dans les
> deux cas le voyant est vert, dans les deux cas il n'a rien mesuré. Le réflexe commun : demander
> **ce que ce contrôle aurait attrapé s'il avait échoué** — s'il n'y a pas de réponse, il ne
> contrôle rien.
> **Piège de build, 2026-09-01** : changer la DISPOSITION d'une structure dans un en-tête inclus
> transitivement (ici un `QDateTime` ajouté à `EcoProgress`, atteint par `LoadAction`) peut

View File

@ -1,3 +1,29 @@
powersync-energy-plugin-nymea (1.15.2+etm48) trixie; urgency=medium
* MOCK — classe `dryRelay` : un relais SEC, qui commute et ne mesure pas. Elle ouvre le cas que
le banc ne savait pas produire : une charge SAINE et NON MESURÉE. Jusqu'ici
`measurement.source: none` n'était atteignable qu'avec un Thing manquant, ce qui traîne
`available: false` et mélange deux affirmations — « rien ne mesure » et « c'est en panne ».
Un relais sec est banal sur le terrain, et parfaitement sain.
* `testHealthyButUnmeasuredDiffersFromFaulty` vérifie ce que RIEN ne pouvait vérifier avant :
deux charges, même `source: none`, `available` opposés, et un `faultCode` sur la seule qui
est en défaut. Ce test n'aurait pas pu s'écrire hier.
* ET L'AJOUT A RÉVÉLÉ QUE LE MOCK AVAIT SA PROPRE PRÉMISSE FAUSSE : `executeAction()`
déréférençait le contrôleur HTTP de CHAQUE Thing sans vérifier, supposant que toute classe en
possède un. Un relais sec n'en a pas besoin. Le plantage fut immédiat et net ; l'hypothèse,
elle, était invisible tant qu'aucune classe ne la contredisait — l'homogénéité du mock la
protégeait, exactement comme elle masquait les cas qu'il ne savait pas produire.
* AGENTS.md — la règle générale sur les identités : une identité dont les DEUX TERMES viennent
de la même source ne vérifie rien. Elle est vraie par construction, et elle rassure là où
rien ne protège. La question n'est donc pas « tient-elle ? » mais « de quoi parle-t-elle
vraiment ? ». Et c'est le MÊME défaut que la contre-épreuve qui ne mord pas, vu de l'autre
côté : dans les deux cas le voyant est vert, dans les deux cas il n'a rien mesuré. Réflexe
commun : demander ce que ce contrôle aurait attrapé s'il avait échoué.
* Suite : simulation 62/62, loadmodel 22/22, charging 48/48 identique à la référence,
spotmarket 32/32, doxygen 0 avertissement.
-- Patrick Schurig <etm.schurig@gmail.com> Wed, 02 Sep 2026 09:00:00 +0200
powersync-energy-plugin-nymea (1.15.2+etm47) trixie; urgency=medium
* R6 — LA MESURABILITÉ DE L'AVANCEMENT SE DÉCLARE DANS `GetLoadConfig`, en `r:`, et non dans

View File

@ -36,10 +36,18 @@ sont désormais **discriminants** — il existe des classes avec et sans. Les r
## Ce qui reste STRUCTURELLEMENT introuvable
**1. `measurement.source: none` pour une charge saine.** Toutes les classes pilotables portent une
puissance. Le seul chemin est une charge dont le **Thing n'existe pas** (câblage manquant) — cas
réel, mais qui traîne avec lui `available: false`. **Une charge saine et non mesurée est
impossible à fabriquer ici**, alors qu'elle est banale sur le terrain (un relais sec).
**1. ~~`measurement.source: none` pour une charge saine.~~ — OUVERT le 2026-09-02.** La classe
`dryRelay` a été ajoutée : elle commute, elle ne mesure pas. Une charge **saine et non mesurée**
est désormais fabricable, et `testHealthyButUnmeasuredDiffersFromFaulty` vérifie ce que rien ne
pouvait vérifier avant — que le moteur **distingue** « rien ne mesure cette charge » de « cette
charge est en panne », deux affirmations que le seul chemin disponible (un Thing manquant)
mélangeait.
> **Ce que l'ajout a révélé au passage** : `executeAction()` déréférençait le contrôleur HTTP de
> **chaque** Thing sans vérifier — le mock supposait que toute classe en possède un. Un relais sec
> n'en a pas besoin, n'ayant rien à mesurer. Le plantage était immédiat et net ; l'hypothèse, elle,
> était invisible tant qu'aucune classe ne la contredisait. **Le mock avait sa propre prémisse
> fausse**, protégée par son homogénéité.
**2. Un compteur par phase incohérent.** `currentPhaseX` et `currentPowerPhaseX` sont posés
ensemble par le mock, dans un rapport fixe de 230. Aucun scénario ne peut produire une tension

View File

@ -457,6 +457,22 @@ QUuid EnergyTestBase::addEnergyStorage(uint capacity, double maxChargingPowerUpp
return response.toMap().value("params").toMap().value("thingId").toUuid();
}
QUuid EnergyTestBase::addDryRelay(quint16 port)
{
QVariantMap portParam;
portParam.insert("paramTypeId", "{9f3d6b18-2c47-4a80-91e5-7d0a4b3c8e26}");
portParam.insert("value", port);
QVariantMap params;
params.insert("thingClassId", mockDryRelayThingClassId.toString());
params.insert("name", QString("Dry relay %1").arg(port));
params.insert("thingParams", QVariantList() << portParam);
QVariant response = injectAndWait("Integrations.AddThing", params);
verifyThingError(response);
return response.toMap().value("params").toMap().value("thingId").toUuid();
}
QUuid EnergyTestBase::addPowerSwitch(double nominalPower, quint16 port)
{
QVariantList thingParams;

View File

@ -49,6 +49,11 @@ static QUuid mockSimpleChargerThingClassId = QUuid("29bcf255-b654-4764-be92-399b
static QUuid mockCarThingClassId = QUuid("4513f801-836e-40a7-8784-c02650a9bdc6");
static QUuid mockEnergyStorageThingClassId = QUuid("d0d5bbf0-249c-46ed-ac6a-5f271b2b0b0f");
static QUuid mockPowerSwitchThingClassId = QUuid("841f8905-d1d7-4053-909f-01123b497747");
//! \brief Relais SEC — il commute, il ne mesure pas. Ouvre le cas « charge saine et NON
//! mesurée », que le mock ne savait pas produire : toutes les classes pilotables portaient une
//! puissance, si bien qu'aucun test ne pouvait distinguer « non mesurée » de « en défaut »
//! (docs/INVENTAIRE_MOCK.md).
static QUuid mockDryRelayThingClassId = QUuid("5b2c7e41-9a06-4d3f-b8c1-6e0f2a7d4b39");
using namespace nymeaserver;
@ -94,6 +99,16 @@ public:
QUuid addSimpleCharger(double maxChargingCurrentUpperLimit = 32, quint16 port = 26659);
QUuid addEnergyStorage(uint capacity = 10, double maxChargingPowerUpperLimit = 5000, double maxDischargingPowerUpperLimit = 11500, quint16 port = 26660);
QUuid addPowerSwitch(double nominalPower = 2000, quint16 port = 26661);
/*!
* \brief Ajoute un RELAIS SEC : il commute, il ne mesure pas.
* \param port Port d'identification (aucun contrôleur n'écoute — la classe n'en a pas besoin).
* \return L'identifiant du Thing créé.
* \note Sans cette classe, une charge « saine mais non mesurée » était impossible à
* fabriquer, et `measurement.source: none` n'était atteignable qu'avec un Thing MANQUANT —
* ce qui traîne `available: false` et mélange deux affirmations distinctes.
*/
QUuid addDryRelay(quint16 port = 26680);
//! Mock thing implémentant l'interface etmvariableload (states currentPowerW / powerSetpoint).
QUuid addEtmVariableLoad(quint16 port = 27001);

View File

@ -7382,6 +7382,99 @@ void Simulation::testProgressMeasurabilityIsDeclaredAtConfigTime()
#endif
}
/*!
* \brief [Inventaire du mock] « Saine mais NON MESURÉE » n'est pas « en défaut ».
*
* \par Le cas que le banc ne savait pas produire
* Toutes les classes pilotables du mock portaient une puissance. `measurement.source: none`
* n'était donc atteignable qu'avec un Thing **manquant** — ce qui traîne `available: false` avec
* lui et **mélange deux affirmations distinctes** : « rien ne mesure cette charge » et « cette
* charge est en panne ». Or un **relais sec** — il commute, il ne mesure pas — est banal sur le
* terrain, et parfaitement sain.
*
* Ce test n'aurait pas pu s'écrire avant la classe `dryRelay` : c'est l'inventaire à froid
* (`docs/INVENTAIRE_MOCK.md`) qui a rendu le manque visible, et le manque était structurel.
*
* \par Ce qu'il épingle
* Deux charges dans le même cycle, l'une saine sans mesure, l'autre au câblage manquant. Elles
* doivent se **distinguer** — mêmes `measurement.source`, `available` opposés.
*/
void Simulation::testHealthyButUnmeasuredDiffersFromFaulty()
{
#ifndef ETM_ARBITRATOR
QSKIP("nécessite ETM_ARBITRATOR.");
#else
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-sec.json";
QFile::remove(cfgPath);
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
cleanupTestCase();
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
initTestCase();
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
QVERIFY(arb);
ThingManager *tm = NymeaCore::instance()->thingManager();
QUuid meterId = addMeter();
m_experiencePlugin->energyManager()->setRootMeter(meterId);
Thing *meter = tm->findConfiguredThing(meterId);
QVERIFY(meter);
meter->setStateValue("connected", true);
meter->setStateValue("currentPower", -5000); // 5 kW exportés
// LE RELAIS SEC : il commute, il ne mesure pas. Il EXISTE, il est sain.
QUuid sec = addDryRelay(26681);
Thing *tSec = tm->findConfiguredThing(sec);
QVERIFY2(tSec, "le relais sec doit être instanciable");
QVERIFY2(!tSec->thingClass().hasStateType("currentPower"),
"un relais sec ne porte PAS de puissance — c'est tout l'objet de la classe");
QVariantList aPoser;
aPoser << QVariantMap{{"id", "ecs-relais-sec"}, {"label", "ECS relais sec"},
{"adapter", "relay-router"}, {"mode", "fixed"},
{"priority", 1}, {"enabled", true}, {"domain", "ecs"},
{"relays", QVariantList() << QVariantMap{{"thingId", sec.toString()},
{"powerW", 1500}}}};
// …et une charge au CÂBLAGE MANQUANT, pour la comparaison.
aPoser << QVariantMap{{"id", "ecs-fantome"}, {"label", "ECS fantôme"},
{"adapter", "relay-router"}, {"mode", "fixed"},
{"priority", 2}, {"enabled", true}, {"domain", "ecs"},
{"relays", QVariantList() << QVariantMap{
{"thingId", "{deadbeef-0000-4000-8000-00000000c0de}"},
{"powerW", 1500}}}};
QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig",
QVariantMap({{"loadConfigs", aPoser}})).toMap()
.value("params").toMap().value("energyError").toString(),
QString("EnergyErrorNoError"));
arb->simulationCallUpdate(utcDateTime(QDate(2026, 9, 2), QTime(13, 0, 0)));
QCoreApplication::processEvents();
QHash<QString, QVariantMap> tele = telemetrieParId(arb->loadTelemetry());
const QVariantMap saine = tele.value("ecs-relais-sec");
const QVariantMap fantome = tele.value("ecs-fantome");
QVERIFY2(!saine.isEmpty() && !fantome.isEmpty(), "les deux charges doivent être publiées");
// 1. LES DEUX ne sont pas mesurées — même source, et c'est normal.
QCOMPARE(saine.value("measurement").toMap().value("source").toString(), QString("none"));
QCOMPARE(fantome.value("measurement").toMap().value("source").toString(), QString("none"));
// 2. MAIS ELLES SE DISTINGUENT, et c'est tout l'objet du lot : l'une est SAINE, l'autre en
// défaut. Avant la classe `dryRelay`, la première était impossible à fabriquer, et rien
// ne pouvait donc vérifier que le moteur les sépare.
QCOMPARE(saine.value("available").toBool(), true);
QCOMPARE(fantome.value("available").toBool(), false);
QCOMPARE(fantome.value("faultCode").toString(), QString("THING_MISSING"));
QVERIFY2(!saine.contains("faultCode"),
"une charge saine ne porte pas de code de défaut — l'absence est l'information");
// 3. Et aucune des deux ne publie `command` : rien ne mesure, donc rien à comparer. Sans le
// relais sec, cette assertion n'aurait porté que sur un cas en défaut.
QVERIFY2(!saine.contains("command"),
"sans mesure, l'écart commandé/mesuré est sans objet — même sur une charge saine");
#endif
}
void Simulation::testCountsSumToTarget()
{
#ifndef ETM_ARBITRATOR

View File

@ -199,6 +199,7 @@ private slots:
void testEcoFloorMissedSaysWhatWasLostAndWhy();
void testCommandDivergenceIsPublishedNotDiagnosed();
void testProgressMeasurabilityIsDeclaredAtConfigTime();
void testHealthyButUnmeasuredDiffersFromFaulty();
void testRecreditIsALoanAgainstObedience();
void testARefusedStateIsDistinguishableFromAnApplied();
void testL4ShedsAPacAndSaysItForcedTheLock();

View File

@ -359,6 +359,15 @@ void IntegrationPluginEnergyMocks::setupThing(ThingSetupInfo *info)
return;
} else if (thing->thingClassId() == dryRelayThingClassId) {
// RELAIS SEC — il commute, il ne mesure pas. C'est toute sa raison d'être : le mock ne
// savait produire aucune charge pilotable SANS puissance, si bien qu'une charge « non
// mesurée mais saine » était impossible à fabriquer, alors qu'un relais sec est banal
// sur le terrain. Sans lui, aucun test ne peut distinguer « non mesurée » de « en
// défaut » (docs/INVENTAIRE_MOCK.md).
info->finish(Thing::ThingErrorNoError);
return;
} else if (thing->thingClassId() == powerSwitchThingClassId) {
EnergyMockController *controller = new EnergyMockController(thing, this);
ParamType paramType = thing->thingClass().paramTypes().findByName("port");
@ -426,9 +435,24 @@ void IntegrationPluginEnergyMocks::executeAction(ThingActionInfo *info)
ActionType actionType = thingClass.actionTypes().findById(action.actionTypeId());
qCDebug(dcEnergyMocks()) << "Executing action" << actionType.displayName() << "on" << thing->name() << action.params();
m_controllers.value(thing)->logActionExecuted(actionType, action);
// Toutes les classes n'ont pas de contrôleur HTTP : le relais SEC n'en a pas besoin,
// n'ayant rien à mesurer ni à piloter à distance. Déréférencer sans vérifier plantait —
// le mock supposait que chaque Thing en possédait un.
if (EnergyMockController *c = m_controllers.value(thing))
c->logActionExecuted(actionType, action);
// Update states
if (thing->thingClassId() == dryRelayThingClassId) {
if (actionType.name() == "power") {
// Aucune écriture de `currentPower` : la classe ne porte pas l'état. C'est
// l'absence qui est l'objet du mécanisme, pas une valeur à zéro.
thing->setStateValue("power",
action.paramValue(actionType.paramTypes().findByName("power").id()).toBool());
}
info->finish(Thing::ThingErrorNoError);
return;
}
if (thing->thingClassId() == chargerThingClassId) {
// Note do this before every action execution since it might hase changed directly within the simulation

View File

@ -941,6 +941,37 @@
}
]
},
{
"name": "dryRelay",
"displayName": "Mocked Dry Relay (relais sec, SANS mesure)",
"id": "5b2c7e41-9a06-4d3f-b8c1-6e0f2a7d4b39",
"createMethods": [
"user"
],
"interfaces": [
"power"
],
"paramTypes": [
{
"id": "9f3d6b18-2c47-4a80-91e5-7d0a4b3c8e26",
"name": "port",
"displayName": "Port",
"type": "uint",
"defaultValue": 26661
}
],
"stateTypes": [
{
"id": "c48a1f60-77b2-4e95-8d3a-0b1e6c9f2a54",
"name": "power",
"displayName": "Power",
"displayNameAction": "Switch power",
"type": "bool",
"defaultValue": false,
"writable": true
}
]
},
{
"name": "etmVariableLoad",
"displayName": "Mocked etmvariableload (ECS/routeur à puissance pilotable)",