From cdd3bc88f3e9ed4bcff80cf8cf1221457e91add5 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Wed, 2 Sep 2026 08:00:51 +0200 Subject: [PATCH] test(mock): le relais sec, et le cas que le banc ne savait pas produire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p --- AGENTS.md | 12 +++ debian-qt5/changelog | 26 ++++++ docs/INVENTAIRE_MOCK.md | 16 +++- tests/auto/common/energytestbase.cpp | 16 ++++ tests/auto/common/energytestbase.h | 15 +++ tests/auto/simulation/simulation.cpp | 93 +++++++++++++++++++ tests/auto/simulation/simulation.h | 1 + .../integrationpluginenergymocks.cpp | 26 +++++- .../integrationpluginenergymocks.json | 31 +++++++ 9 files changed, 231 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index bceb0cd..b717087 100644 --- a/AGENTS.md +++ b/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 diff --git a/debian-qt5/changelog b/debian-qt5/changelog index 8924397..3b2ec5c 100644 --- a/debian-qt5/changelog +++ b/debian-qt5/changelog @@ -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 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 diff --git a/docs/INVENTAIRE_MOCK.md b/docs/INVENTAIRE_MOCK.md index 3282bc6..047dc45 100644 --- a/docs/INVENTAIRE_MOCK.md +++ b/docs/INVENTAIRE_MOCK.md @@ -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 diff --git a/tests/auto/common/energytestbase.cpp b/tests/auto/common/energytestbase.cpp index ae9b966..a45e2e8 100644 --- a/tests/auto/common/energytestbase.cpp +++ b/tests/auto/common/energytestbase.cpp @@ -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; diff --git a/tests/auto/common/energytestbase.h b/tests/auto/common/energytestbase.h index 04c8f04..a1e110f 100644 --- a/tests/auto/common/energytestbase.h +++ b/tests/auto/common/energytestbase.h @@ -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); diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index 2e240a6..4ef3e4e 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -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(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 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 diff --git a/tests/auto/simulation/simulation.h b/tests/auto/simulation/simulation.h index 9eb2d73..828dcc7 100644 --- a/tests/auto/simulation/simulation.h +++ b/tests/auto/simulation/simulation.h @@ -199,6 +199,7 @@ private slots: void testEcoFloorMissedSaysWhatWasLostAndWhy(); void testCommandDivergenceIsPublishedNotDiagnosed(); void testProgressMeasurabilityIsDeclaredAtConfigTime(); + void testHealthyButUnmeasuredDiffersFromFaulty(); void testRecreditIsALoanAgainstObedience(); void testARefusedStateIsDistinguishableFromAnApplied(); void testL4ShedsAPacAndSaysItForcedTheLock(); diff --git a/tests/mocks/plugins/energymocks/integrationpluginenergymocks.cpp b/tests/mocks/plugins/energymocks/integrationpluginenergymocks.cpp index 918f9b8..ed01837 100644 --- a/tests/mocks/plugins/energymocks/integrationpluginenergymocks.cpp +++ b/tests/mocks/plugins/energymocks/integrationpluginenergymocks.cpp @@ -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 diff --git a/tests/mocks/plugins/energymocks/integrationpluginenergymocks.json b/tests/mocks/plugins/energymocks/integrationpluginenergymocks.json index 410f984..80cb120 100644 --- a/tests/mocks/plugins/energymocks/integrationpluginenergymocks.json +++ b/tests/mocks/plugins/energymocks/integrationpluginenergymocks.json @@ -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)",