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:
parent
ccf05cea87
commit
cdd3bc88f3
12
AGENTS.md
12
AGENTS.md
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -199,6 +199,7 @@ private slots:
|
||||
void testEcoFloorMissedSaysWhatWasLostAndWhy();
|
||||
void testCommandDivergenceIsPublishedNotDiagnosed();
|
||||
void testProgressMeasurabilityIsDeclaredAtConfigTime();
|
||||
void testHealthyButUnmeasuredDiffersFromFaulty();
|
||||
void testRecreditIsALoanAgainstObedience();
|
||||
void testARefusedStateIsDistinguishableFromAnApplied();
|
||||
void testL4ShedsAPacAndSaysItForcedTheLock();
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user