test(mock): les deux derniers manques — compteur sans énergie, sonde thermique
`meterNoEnergy` remplace le `powerSwitch` détourné en instrument de mesure. Le
détour marchait — le moteur ne teste que `hasStateType("totalEnergyConsumed")` —
mais il modélisait un montage que personne ne fait, et faisait tenir DEUX rôles à
la même Thing dans le même scénario : relais d'une charge et compteur d'une autre.
La classe ne déclare PAS `energymeter` : cette interface EXIGE `totalEnergyConsumed`
et `totalEnergyProduced`. Promettre une interface qu'on ne tient pas ferait mentir
le mock exactement là où il doit être littéral. `smartmeter` est la base sans état
obligatoire, et c'est précisément ce qu'est ce matériel.
`thermalProbe` ouvre le volet thermique. La branche « sonde présente » de
attachMeasurements() était écrite depuis toujours et n'avait JAMAIS été parcourue :
aucune classe du mock ne portait l'état `temperature`. C'était la seule entrée du
volet thermique, et rien ne l'exerçait.
CE QUE L'OUVERTURE A RÉVÉLÉ, comme les trois précédentes. `setupThing()` du mock
n'avait AUCUNE sortie par défaut : une classe sans branche ne rencontrait jamais
d'`info->finish()`. Le setup ne échouait pas — il ne se terminait jamais. Le
symptôme est un Thing éternellement « en cours de configuration » et un test qui
expire ailleurs, très loin de la cause. Même mécanisme que le contrôleur
déréférencé pour toute classe : une hypothèse du mock que son homogénéité
protégeait. Une classe oubliée se dit maintenant, en qCWarning + SetupFailed.
UNE GARDE A MORDU sur son auteur le jour même. La première version du test
thermique lisait `loads[]` sans compteur racine — donc une liste vide, et trois
assertions qui ne se seraient jamais exécutées. `QCOMPARE(parId.count(), 3)` l'a
attrapé. C'est la parade écrite dans INVENTAIRE_MOCK.md, appliquée à elle-même.
RELEVÉ, NON CORRIGÉ : une sonde DÉSIGNÉE mais incapable produit la même charge
utile qu'aucune sonde du tout (`temperatureC` absent), alors que le geste de
réparation diffère — désigner une sonde, ou en changer. Silence de la famille
`progressUnmeasurableCause` ; le fermer demande un catalogue de causes thermiques
qui n'existe pas encore.
Suites : simulation 67/67 (+1), loadmodel 23/23, charging 48/48, spotmarket 32/32.
This commit is contained in:
parent
ae5e8639c4
commit
119cffc745
@ -62,9 +62,16 @@ ne bouge.
|
|||||||
> conclusion tenait, la raison non**, et rien ne pouvait le révéler tant que le déséquilibre était
|
> conclusion tenait, la raison non**, et rien ne pouvait le révéler tant que le déséquilibre était
|
||||||
> infabricable.
|
> infabricable.
|
||||||
|
|
||||||
**3. `temperature`.** Le code teste `hasState("temperature")` et **aucune classe du mock ne le
|
**3. ~~`temperature`.~~ — OUVERT le 2026-09-05.** La classe `thermalProbe` porte l'état, et
|
||||||
porte** : la branche « sonde présente » n'a jamais été exécutée. Elle est écrite, elle n'est pas
|
`testThermalProbeFeedsTelemetry` exerce enfin la branche « sonde présente » de
|
||||||
testée.
|
`attachMeasurements()`, écrite depuis toujours et **jamais parcourue**. C'était la seule entrée du
|
||||||
|
volet thermique, et rien ne l'exerçait.
|
||||||
|
|
||||||
|
> **Ce qu'il reste à dire, et qui n'est pas corrigé ici** : une sonde DÉSIGNÉE mais incapable —
|
||||||
|
> un Thing sans état `temperature` — produit exactement la même charge utile qu'aucune sonde du
|
||||||
|
> tout (`temperatureC` absent). Le geste de réparation, lui, diffère : désigner une sonde, ou en
|
||||||
|
> changer. C'est un silence de la famille `progressUnmeasurableCause`, relevé par le test et
|
||||||
|
> laissé ouvert : le fermer demande un catalogue de causes thermiques qui n'existe pas encore.
|
||||||
|
|
||||||
**4. ~~Une triphasée déséquilibrée.~~ — OUVERT le 2026-09-03.**
|
**4. ~~Une triphasée déséquilibrée.~~ — OUVERT le 2026-09-03.**
|
||||||
`testWorstPhaseBindsWhenPhasesAreUnbalanced` exerce enfin `LM-1210-a` : phase B saturée à 30 A
|
`testWorstPhaseBindsWhenPhasesAreUnbalanced` exerce enfin `LM-1210-a` : phase B saturée à 30 A
|
||||||
@ -105,12 +112,35 @@ monotone — le mock fabriquerait l'anomalie que le moteur est censé détecter.
|
|||||||
**Quatrième occurrence.** Après les six états cohérents du `rootmeter`, les classes portant toutes
|
**Quatrième occurrence.** Après les six états cohérents du `rootmeter`, les classes portant toutes
|
||||||
une puissance, et la triphasée équilibrée.
|
une puissance, et la triphasée équilibrée.
|
||||||
|
|
||||||
|
**6. ~~Le compteur sans énergie et la sonde thermique.~~ — OUVERTS le 2026-09-05**, et les deux
|
||||||
|
derniers de la liste. `meterNoEnergy` remplace le `powerSwitch` détourné en instrument de mesure —
|
||||||
|
un montage que personne ne fait, et qui faisait tenir DEUX rôles à la même Thing dans le même
|
||||||
|
scénario. `thermalProbe` ouvre le volet thermique.
|
||||||
|
|
||||||
|
> **Et l'ouverture a révélé un défaut, comme les trois précédentes.** `setupThing()` du mock
|
||||||
|
> n'avait **aucune sortie par défaut** : une classe sans branche ne rencontrait jamais de
|
||||||
|
> `info->finish()`. Le setup ne échouait pas — **il ne se terminait jamais**. Le symptôme est un
|
||||||
|
> Thing éternellement « en cours de configuration » et un test qui expire ailleurs, très loin de
|
||||||
|
> la cause.
|
||||||
|
>
|
||||||
|
> C'est le même mécanisme que le contrôleur déréférencé pour toute classe : **une hypothèse du
|
||||||
|
> mock que son homogénéité protégeait.** Tant que chaque classe avait sa branche, l'absence de
|
||||||
|
> sortie par défaut ne pouvait pas se voir. Corrigé : une classe oubliée se dit maintenant en
|
||||||
|
> `qCWarning` et rend `ThingErrorSetupFailed`.
|
||||||
|
>
|
||||||
|
> **Et une garde a mordu au passage** : la première version du test thermique lisait `loads[]`
|
||||||
|
> sans compteur racine — donc une liste vide, et trois assertions qui ne se seraient jamais
|
||||||
|
> exécutées. `QCOMPARE(parId.count(), 3)` l'a attrapé. C'est la parade écrite plus haut dans ce
|
||||||
|
> document, appliquée le jour même à son auteur.
|
||||||
|
|
||||||
## Ce qu'il faudrait ajouter, par ordre d'utilité
|
## Ce qu'il faudrait ajouter, par ordre d'utilité
|
||||||
|
|
||||||
1. **Une classe pilotable SANS aucune puissance** — un relais sec. Débloque `source: none` sur
|
1. **Une classe pilotable SANS aucune puissance** — un relais sec. Débloque `source: none` sur
|
||||||
une charge saine, et sépare enfin « non mesurée » de « en panne ».
|
une charge saine, et sépare enfin « non mesurée » de « en panne ».
|
||||||
2. **Un compteur sans `totalEnergyConsumed`** en tant que classe déclarée, plutôt qu'un
|
2. ~~**Un compteur sans `totalEnergyConsumed`** en tant que classe déclarée.~~ — **FAIT le
|
||||||
`powerSwitch` détourné comme aujourd'hui.
|
2026-09-05** : `meterNoEnergy`. Elle ne déclare PAS l'interface `energymeter`, qui exige
|
||||||
|
`totalEnergyConsumed` — promettre une interface qu'on ne tient pas ferait mentir le mock là où
|
||||||
|
il doit être littéral.
|
||||||
3. **Une tension par phase réglable** indépendamment des puissances — pour exercer le correctif
|
3. **Une tension par phase réglable** indépendamment des puissances — pour exercer le correctif
|
||||||
amont ailleurs qu'en inversant la fonction.
|
amont ailleurs qu'en inversant la fonction.
|
||||||
|
|
||||||
|
|||||||
@ -501,6 +501,38 @@ QUuid EnergyTestBase::addDryRelay(quint16 port)
|
|||||||
return response.toMap().value("params").toMap().value("thingId").toUuid();
|
return response.toMap().value("params").toMap().value("thingId").toUuid();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
QUuid EnergyTestBase::addMeterNoEnergy(quint16 port)
|
||||||
|
{
|
||||||
|
QVariantMap portParam;
|
||||||
|
portParam.insert("paramTypeId", "{eec1f5a2-5c59-4ce9-a5ae-5dc7cad70772}");
|
||||||
|
portParam.insert("value", port);
|
||||||
|
|
||||||
|
QVariantMap params;
|
||||||
|
params.insert("thingClassId", mockMeterNoEnergyThingClassId.toString());
|
||||||
|
params.insert("name", QString("Meter without energy %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::addThermalProbe(quint16 port)
|
||||||
|
{
|
||||||
|
QVariantMap portParam;
|
||||||
|
portParam.insert("paramTypeId", "{b898397c-c291-47f9-82ec-662250b932e1}");
|
||||||
|
portParam.insert("value", port);
|
||||||
|
|
||||||
|
QVariantMap params;
|
||||||
|
params.insert("thingClassId", mockThermalProbeThingClassId.toString());
|
||||||
|
params.insert("name", QString("Thermal probe %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)
|
QUuid EnergyTestBase::addPowerSwitch(double nominalPower, quint16 port)
|
||||||
{
|
{
|
||||||
QVariantList thingParams;
|
QVariantList thingParams;
|
||||||
|
|||||||
@ -59,6 +59,8 @@ static QUuid mockPowerSwitchThingClassId = QUuid("841f8905-d1d7-4053-909f-01123b
|
|||||||
//! puissance, si bien qu'aucun test ne pouvait distinguer « non mesurée » de « en défaut »
|
//! puissance, si bien qu'aucun test ne pouvait distinguer « non mesurée » de « en défaut »
|
||||||
//! (docs/INVENTAIRE_MOCK.md).
|
//! (docs/INVENTAIRE_MOCK.md).
|
||||||
static QUuid mockDryRelayThingClassId = QUuid("5b2c7e41-9a06-4d3f-b8c1-6e0f2a7d4b39");
|
static QUuid mockDryRelayThingClassId = QUuid("5b2c7e41-9a06-4d3f-b8c1-6e0f2a7d4b39");
|
||||||
|
static QUuid mockMeterNoEnergyThingClassId = QUuid("50dacc7b-7142-4716-8f59-97d6ee81f39c");
|
||||||
|
static QUuid mockThermalProbeThingClassId = QUuid("37e61ea3-e4b2-4d3e-bc82-66e350c29f66");
|
||||||
|
|
||||||
using namespace nymeaserver;
|
using namespace nymeaserver;
|
||||||
|
|
||||||
@ -118,6 +120,25 @@ public:
|
|||||||
* ce qui traîne `available: false` et mélange deux affirmations distinctes.
|
* ce qui traîne `available: false` et mélange deux affirmations distinctes.
|
||||||
*/
|
*/
|
||||||
QUuid addDryRelay(quint16 port = 26680);
|
QUuid addDryRelay(quint16 port = 26680);
|
||||||
|
|
||||||
|
/*!
|
||||||
|
* \brief Ajoute un COMPTEUR SANS CUMUL D'ÉNERGIE : il publie une puissance, il ne totalise rien.
|
||||||
|
* \param port Port du contrôleur mock.
|
||||||
|
* \return L'identifiant du Thing créé.
|
||||||
|
* \note Remplace le détournement d'un \c powerSwitch en rôle de compteur — un interrupteur
|
||||||
|
* dans le rôle d'un instrument, et la même Thing tenant deux rôles dans le même scénario.
|
||||||
|
* C'est le cas `meterWithoutEnergy`, mesuré sur l'ECS du banc le 2026-09-01.
|
||||||
|
*/
|
||||||
|
QUuid addMeterNoEnergy(quint16 port = 26671);
|
||||||
|
|
||||||
|
/*!
|
||||||
|
* \brief Ajoute une SONDE DE TEMPÉRATURE — la seule entrée du volet thermique.
|
||||||
|
* \param port Port du contrôleur mock.
|
||||||
|
* \return L'identifiant du Thing créé.
|
||||||
|
* \note Aucune classe du mock ne portait l'état \c temperature : la branche « sonde
|
||||||
|
* présente » de \c attachMeasurements() était écrite et n'avait jamais été exécutée.
|
||||||
|
*/
|
||||||
|
QUuid addThermalProbe(quint16 port = 26681);
|
||||||
//! Mock thing implémentant l'interface etmvariableload (states currentPowerW / powerSetpoint).
|
//! Mock thing implémentant l'interface etmvariableload (states currentPowerW / powerSetpoint).
|
||||||
QUuid addEtmVariableLoad(quint16 port = 27001);
|
QUuid addEtmVariableLoad(quint16 port = 27001);
|
||||||
|
|
||||||
|
|||||||
@ -7419,8 +7419,9 @@ void Simulation::testRecreditIsALoanAgainstObedience()
|
|||||||
*
|
*
|
||||||
* \par Et `meterThingId` ne permet pas de le déduire
|
* \par Et `meterThingId` ne permet pas de le déduire
|
||||||
* Un compteur peut publier `currentPower` **sans** `totalEnergyConsumed` : mesuré le 2026-09-01
|
* Un compteur peut publier `currentPower` **sans** `totalEnergyConsumed` : mesuré le 2026-09-01
|
||||||
* sur l'ECS du banc, et reproduit ici avec un `powerSwitch` désigné comme compteur. Un client qui
|
* sur l'ECS du banc, et reproduit ici par la classe `meterNoEnergy` — un compteur DÉCLARÉ tel,
|
||||||
* déduirait la mesurabilité de la présence du champ se tromperait sur ce cas précis.
|
* et non plus un `powerSwitch` détourné en instrument de mesure. Un client qui déduirait la
|
||||||
|
* mesurabilité de la présence du champ se tromperait sur ce cas précis.
|
||||||
*/
|
*/
|
||||||
void Simulation::testProgressMeasurabilityIsDeclaredAtConfigTime()
|
void Simulation::testProgressMeasurabilityIsDeclaredAtConfigTime()
|
||||||
{
|
{
|
||||||
@ -7436,10 +7437,25 @@ void Simulation::testProgressMeasurabilityIsDeclaredAtConfigTime()
|
|||||||
|
|
||||||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||||||
QUuid compteur = addMeter(26702); // meter : porte totalEnergyConsumed
|
QUuid compteur = addMeter(26702); // meter : porte totalEnergyConsumed
|
||||||
QUuid r1 = addPowerSwitch(1000, 26703); // powerSwitch : power + currentPower SEULEMENT
|
QUuid r1 = addPowerSwitch(1000, 26703);
|
||||||
QUuid r2 = addPowerSwitch(1000, 26704);
|
QUuid r2 = addPowerSwitch(1000, 26704);
|
||||||
QUuid r3 = addPowerSwitch(1000, 26705);
|
QUuid r3 = addPowerSwitch(1000, 26705);
|
||||||
|
// COMPTEUR SANS CUMUL, classe déclarée — plus un `powerSwitch` détourné en instrument de
|
||||||
|
// mesure. Le détour marchait (le moteur ne teste que `hasStateType`), mais il modélisait un
|
||||||
|
// montage que personne ne fait, et faisait tenir DEUX rôles à la même Thing dans le même
|
||||||
|
// scénario : relais de « sans-compteur » et compteur de « compteur-sans-energie ».
|
||||||
|
QUuid compteurSansEnergie = addMeterNoEnergy(26706);
|
||||||
QVERIFY(tm->findConfiguredThing(compteur) && tm->findConfiguredThing(r1));
|
QVERIFY(tm->findConfiguredThing(compteur) && tm->findConfiguredThing(r1));
|
||||||
|
QVERIFY2(tm->findConfiguredThing(compteurSansEnergie),
|
||||||
|
"la classe meterNoEnergy doit être configurable — sinon la branche par défaut du "
|
||||||
|
"mock a parlé, et c'est elle qu'il faut lire");
|
||||||
|
// Le contrat de la classe, vérifié plutôt que supposé : elle publie une puissance et ne
|
||||||
|
// totalise RIEN. Si un jour quelqu'un lui ajoutait le cumul, ce test-ci se tairait et le
|
||||||
|
// cas `meterWithoutEnergy` redeviendrait infabricable sans que rien ne le signale.
|
||||||
|
Thing *tSans = tm->findConfiguredThing(compteurSansEnergie);
|
||||||
|
QVERIFY2(tSans->thingClass().hasStateType("currentPower"), "un compteur publie une puissance");
|
||||||
|
QVERIFY2(!tSans->thingClass().hasStateType("totalEnergyConsumed"),
|
||||||
|
"meterNoEnergy ne doit PAS totaliser — c'est toute sa raison d'être");
|
||||||
|
|
||||||
// Écrit par la FRONTIÈRE RPC, comme l'app le ferait — c'est le magasin que le handler
|
// Écrit par la FRONTIÈRE RPC, comme l'app le ferait — c'est le magasin que le handler
|
||||||
// connaît. Un store créé à côté ne serait pas celui qu'il interroge : constaté ici même,
|
// connaît. Un store créé à côté ne serait pas celui qu'il interroge : constaté ici même,
|
||||||
@ -7457,7 +7473,7 @@ void Simulation::testProgressMeasurabilityIsDeclaredAtConfigTime()
|
|||||||
};
|
};
|
||||||
aPoser << relais("sans-compteur", r1, QString());
|
aPoser << relais("sans-compteur", r1, QString());
|
||||||
aPoser << relais("compteur-ok", r2, compteur.toString());
|
aPoser << relais("compteur-ok", r2, compteur.toString());
|
||||||
aPoser << relais("compteur-sans-energie", r3, r1.toString());
|
aPoser << relais("compteur-sans-energie", r3, compteurSansEnergie.toString());
|
||||||
QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig",
|
QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig",
|
||||||
QVariantMap({{"loadConfigs", aPoser}})).toMap()
|
QVariantMap({{"loadConfigs", aPoser}})).toMap()
|
||||||
.value("params").toMap().value("energyError").toString(),
|
.value("params").toMap().value("energyError").toString(),
|
||||||
@ -7493,6 +7509,118 @@ void Simulation::testProgressMeasurabilityIsDeclaredAtConfigTime()
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*!
|
||||||
|
* \brief [Inventaire du mock] La branche THERMIQUE, écrite depuis toujours et jamais exécutée.
|
||||||
|
*
|
||||||
|
* \par Le manque
|
||||||
|
* `attachMeasurements()` teste `hasState("temperature")` sur le Thing désigné par
|
||||||
|
* `sensorThingId`. **Aucune classe du mock ne portait cet état** : la branche « sonde présente »
|
||||||
|
* n'avait jamais été parcourue, et rien n'exerçait la seule entrée du volet thermique.
|
||||||
|
*
|
||||||
|
* \par Les trois cas, et le troisième est celui qu'on n'aurait pas pensé à écrire
|
||||||
|
* Aucune sonde désignée, une sonde qui mesure, et une sonde DÉSIGNÉE QUI NE MESURE PAS — un
|
||||||
|
* Thing existant sans état `temperature`. Le troisième produit la même charge utile que le
|
||||||
|
* premier (`temperatureC` absent), alors que le geste de réparation n'est pas le même : dans un
|
||||||
|
* cas il faut désigner une sonde, dans l'autre en changer.
|
||||||
|
*/
|
||||||
|
void Simulation::testThermalProbeFeedsTelemetry()
|
||||||
|
{
|
||||||
|
#ifndef ETM_ARBITRATOR
|
||||||
|
QSKIP("nécessite ETM_ARBITRATOR.");
|
||||||
|
#else
|
||||||
|
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-thermo.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();
|
||||||
|
|
||||||
|
// Sans compteur racine, AUCUN cycle d'arbitrage ne tourne et `loads[]` reste vide : la
|
||||||
|
// garde d'assertion vacante ci-dessous l'a attrapé à la première exécution de ce test.
|
||||||
|
QUuid meterId = addMeter(26710);
|
||||||
|
m_experiencePlugin->energyManager()->setRootMeter(meterId);
|
||||||
|
Thing *meter = tm->findConfiguredThing(meterId);
|
||||||
|
QVERIFY(meter);
|
||||||
|
meter->setStateValue("connected", true);
|
||||||
|
|
||||||
|
QUuid r1 = addPowerSwitch(1000, 26711);
|
||||||
|
QUuid r2 = addPowerSwitch(1000, 26712);
|
||||||
|
QUuid r3 = addPowerSwitch(1000, 26713);
|
||||||
|
QUuid sonde = addThermalProbe(26714);
|
||||||
|
QVERIFY2(tm->findConfiguredThing(sonde), "la classe thermalProbe doit être configurable");
|
||||||
|
|
||||||
|
Thing *tSonde = tm->findConfiguredThing(sonde);
|
||||||
|
QVERIFY2(tSonde->thingClass().hasStateType("temperature"),
|
||||||
|
"sans cet état, la branche testée reste inatteignable et le test serait VACANT");
|
||||||
|
tSonde->setStateValue("temperature", 54.5);
|
||||||
|
|
||||||
|
int rang = 0;
|
||||||
|
QVariantList aPoser;
|
||||||
|
auto charge = [&](const QString &id, const QUuid &relais, const QString &capteur) {
|
||||||
|
++rang;
|
||||||
|
QVariantMap m{{"id", id}, {"label", id}, {"adapter", "relay-router"}, {"mode", "fixed"},
|
||||||
|
{"priority", rang}, {"enabled", true}, {"domain", "ecs"},
|
||||||
|
{"relays", QVariantList() << QVariantMap{{"thingId", relais.toString()},
|
||||||
|
{"powerW", 1000}}}};
|
||||||
|
if (!capteur.isEmpty()) m.insert("sensorThingId", capteur);
|
||||||
|
return m;
|
||||||
|
};
|
||||||
|
aPoser << charge("sans-sonde", r1, QString());
|
||||||
|
aPoser << charge("avec-sonde", r2, sonde.toString());
|
||||||
|
// Une sonde DÉSIGNÉE qui ne mesure pas la température : un relais, ici. Le cas existe sur le
|
||||||
|
// terrain dès qu'on désigne le mauvais Thing, et il ne se distingue pas du premier.
|
||||||
|
aPoser << charge("sonde-muette", r3, r1.toString());
|
||||||
|
QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig",
|
||||||
|
QVariantMap({{"loadConfigs", aPoser}})).toMap()
|
||||||
|
.value("params").toMap().value("energyError").toString(),
|
||||||
|
QString("EnergyErrorNoError"));
|
||||||
|
|
||||||
|
// Cycle DÉCLENCHÉ par l'horloge de simulation, jamais attendu de l'horloge murale.
|
||||||
|
const QDateTime t0 = utcDateTime(QDate(2026, 9, 5), QTime(13, 0, 0));
|
||||||
|
meter->setStateValue("currentPower", -6000); // 6000 W exportés : de quoi servir
|
||||||
|
arb->simulationCallUpdate(t0);
|
||||||
|
QCoreApplication::processEvents();
|
||||||
|
|
||||||
|
QVariantList loads = injectAndWait("NymeaEnergy.GetLoadTelemetry").toMap()
|
||||||
|
.value("params").toMap().value("loads").toList();
|
||||||
|
QHash<QString, QVariantMap> parId;
|
||||||
|
for (const QVariant &v : loads) parId.insert(v.toMap().value("loadId").toString(), v.toMap());
|
||||||
|
|
||||||
|
// Garde contre l'assertion VACANTE — sans les trois charges, tout ce qui suit passerait
|
||||||
|
// sans rien vérifier.
|
||||||
|
QCOMPARE(parId.count(), 3);
|
||||||
|
|
||||||
|
// 1. SONDE QUI MESURE — la branche qui n'avait jamais été exécutée.
|
||||||
|
QVERIFY2(parId.value("avec-sonde").contains("temperatureC"),
|
||||||
|
"une sonde désignée ET capable doit publier sa lecture");
|
||||||
|
QCOMPARE(parId.value("avec-sonde").value("temperatureC").toDouble(), 54.5);
|
||||||
|
|
||||||
|
// 2. AUCUNE SONDE — le champ est ABSENT, jamais zéro. Un 0 se lirait « il fait 0 °C ».
|
||||||
|
QVERIFY2(!parId.value("sans-sonde").contains("temperatureC"),
|
||||||
|
"sans sonde, le champ est absent — un zéro serait une mesure");
|
||||||
|
|
||||||
|
// 3. SONDE DÉSIGNÉE MAIS MUETTE — même charge utile que le cas 2, geste de réparation
|
||||||
|
// DIFFÉRENT. C'est un silence de la famille `progressUnmeasurableCause`, relevé ici et
|
||||||
|
// non corrigé : le corriger demande un catalogue de causes thermiques qui n'existe pas.
|
||||||
|
QVERIFY2(!parId.value("sonde-muette").contains("temperatureC"),
|
||||||
|
"une sonde incapable ne publie rien — mais rien ne dit POURQUOI");
|
||||||
|
|
||||||
|
// 4. La lecture SUIT la sonde : une valeur figée passerait les trois assertions ci-dessus.
|
||||||
|
tSonde->setStateValue("temperature", 61.25);
|
||||||
|
arb->simulationCallUpdate(t0.addSecs(60));
|
||||||
|
QCoreApplication::processEvents();
|
||||||
|
loads = injectAndWait("NymeaEnergy.GetLoadTelemetry").toMap()
|
||||||
|
.value("params").toMap().value("loads").toList();
|
||||||
|
for (const QVariant &v : loads)
|
||||||
|
if (v.toMap().value("loadId").toString() == "avec-sonde")
|
||||||
|
QCOMPARE(v.toMap().value("temperatureC").toDouble(), 61.25);
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
/*!
|
/*!
|
||||||
* \brief [Inventaire du mock] « Saine mais NON MESURÉE » n'est pas « en défaut ».
|
* \brief [Inventaire du mock] « Saine mais NON MESURÉE » n'est pas « en défaut ».
|
||||||
*
|
*
|
||||||
|
|||||||
@ -199,6 +199,7 @@ private slots:
|
|||||||
void testEcoFloorMissedSaysWhatWasLostAndWhy();
|
void testEcoFloorMissedSaysWhatWasLostAndWhy();
|
||||||
void testCommandDivergenceIsPublishedNotDiagnosed();
|
void testCommandDivergenceIsPublishedNotDiagnosed();
|
||||||
void testProgressMeasurabilityIsDeclaredAtConfigTime();
|
void testProgressMeasurabilityIsDeclaredAtConfigTime();
|
||||||
|
void testThermalProbeFeedsTelemetry();
|
||||||
void testHealthyButUnmeasuredDiffersFromFaulty();
|
void testHealthyButUnmeasuredDiffersFromFaulty();
|
||||||
void testLifetimeCounterDropNeverRebasesTheProgress();
|
void testLifetimeCounterDropNeverRebasesTheProgress();
|
||||||
void testWorstPhaseBindsWhenPhasesAreUnbalanced();
|
void testWorstPhaseBindsWhenPhasesAreUnbalanced();
|
||||||
|
|||||||
@ -487,7 +487,72 @@ void IntegrationPluginEnergyMocks::setupThing(ThingSetupInfo *info)
|
|||||||
qCDebug(dcEnergyMocks()) << "Setting up etmVariableLoad" << thing->name() << "finished successfully";
|
qCDebug(dcEnergyMocks()) << "Setting up etmVariableLoad" << thing->name() << "finished successfully";
|
||||||
info->finish(Thing::ThingErrorNoError);
|
info->finish(Thing::ThingErrorNoError);
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
} else if (thing->thingClassId() == meterNoEnergyThingClassId) {
|
||||||
|
// COMPTEUR SANS CUMUL D'ÉNERGIE — il publie une puissance, il ne totalise rien.
|
||||||
|
//
|
||||||
|
// Le cas était jusqu'ici fabriqué en désignant un `powerSwitch` comme compteur : un
|
||||||
|
// INTERRUPTEUR dans le rôle d'un instrument de mesure, et de surcroît la même Thing
|
||||||
|
// jouant deux rôles dans le même scénario. Le moteur ne teste que
|
||||||
|
// `hasStateType("totalEnergyConsumed")`, donc le détour marchait — mais il modélisait
|
||||||
|
// un montage que personne ne fait, et il empêchait de voir ce que le vrai cas produit.
|
||||||
|
//
|
||||||
|
// La classe ne déclare PAS `energymeter` : cette interface EXIGE `totalEnergyConsumed`
|
||||||
|
// et `totalEnergyProduced`. Promettre une interface qu'on ne tient pas ferait mentir le
|
||||||
|
// mock exactement là où il doit être littéral. `smartmeter` est la base sans état
|
||||||
|
// obligatoire, et c'est précisément ce qu'est ce matériel — mesuré sur l'ECS du banc
|
||||||
|
// le 2026-09-01.
|
||||||
|
EnergyMockController *controller = new EnergyMockController(thing, this);
|
||||||
|
ParamType paramType = thing->thingClass().paramTypes().findByName("port");
|
||||||
|
quint16 port = thing->paramValue(paramType.id()).toUInt();
|
||||||
|
if (!controller->listen(QHostAddress::Any, port)) {
|
||||||
|
qCWarning(dcEnergyMocks()) << "Failed to start mock controller on port" << controller->errorString();
|
||||||
|
delete controller;
|
||||||
|
info->finish(Thing::ThingErrorThingInUse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
connect(controller, &EnergyMockController::updateStateRequestReceived, thing, [=](const QUrlQuery &query){
|
||||||
|
for (const QString &etat : {QStringLiteral("currentPower"),
|
||||||
|
QStringLiteral("voltagePhaseA"),
|
||||||
|
QStringLiteral("currentPhaseA")}) {
|
||||||
|
if (query.hasQueryItem(etat))
|
||||||
|
thing->setStateValue(etat, QVariant(query.queryItemValue(etat)).toDouble());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
m_controllers.insert(thing, controller);
|
||||||
|
info->finish(Thing::ThingErrorNoError);
|
||||||
|
return;
|
||||||
|
|
||||||
|
} else if (thing->thingClassId() == thermalProbeThingClassId) {
|
||||||
|
// SONDE DE TEMPÉRATURE — la seule entrée du volet thermique, et rien ne l'exerçait.
|
||||||
|
// `EnergyArbitrator::attachMeasurements()` teste `hasState("temperature")` sur le Thing
|
||||||
|
// désigné par `sensorThingId` : aucune classe du mock ne portait cet état, donc la
|
||||||
|
// branche « sonde présente » était écrite et n'avait JAMAIS été exécutée.
|
||||||
|
EnergyMockController *controller = new EnergyMockController(thing, this);
|
||||||
|
ParamType paramType = thing->thingClass().paramTypes().findByName("port");
|
||||||
|
quint16 port = thing->paramValue(paramType.id()).toUInt();
|
||||||
|
if (!controller->listen(QHostAddress::Any, port)) {
|
||||||
|
qCWarning(dcEnergyMocks()) << "Failed to start mock controller on port" << controller->errorString();
|
||||||
|
delete controller;
|
||||||
|
info->finish(Thing::ThingErrorThingInUse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
connect(controller, &EnergyMockController::updateStateRequestReceived, thing, [=](const QUrlQuery &query){
|
||||||
|
if (query.hasQueryItem("temperature"))
|
||||||
|
thing->setStateValue("temperature", QVariant(query.queryItemValue("temperature")).toDouble());
|
||||||
|
});
|
||||||
|
m_controllers.insert(thing, controller);
|
||||||
|
info->finish(Thing::ThingErrorNoError);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SORTIE PAR DÉFAUT, et elle manquait. Une classe sans branche ne tombait sur AUCUN
|
||||||
|
// `info->finish()` : le setup ne échouait pas, il ne se terminait jamais. Le symptôme est
|
||||||
|
// un Thing éternellement « en cours de configuration » et un test qui expire ailleurs, très
|
||||||
|
// loin de la cause. Une classe oubliée doit se dire, pas se taire.
|
||||||
|
qCWarning(dcEnergyMocks()) << "Aucune branche de setup pour la classe"
|
||||||
|
<< thing->thingClass().name() << "— Thing non configurable.";
|
||||||
|
info->finish(Thing::ThingErrorSetupFailed);
|
||||||
}
|
}
|
||||||
|
|
||||||
void IntegrationPluginEnergyMocks::thingRemoved(Thing *thing)
|
void IntegrationPluginEnergyMocks::thingRemoved(Thing *thing)
|
||||||
|
|||||||
@ -1127,8 +1127,92 @@
|
|||||||
"writable": true
|
"writable": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "meterNoEnergy",
|
||||||
|
"displayName": "Mocked Meter (puissance SEULE, sans cumul d'énergie)",
|
||||||
|
"id": "50dacc7b-7142-4716-8f59-97d6ee81f39c",
|
||||||
|
"createMethods": [
|
||||||
|
"user"
|
||||||
|
],
|
||||||
|
"interfaces": [
|
||||||
|
"smartmeter"
|
||||||
|
],
|
||||||
|
"paramTypes": [
|
||||||
|
{
|
||||||
|
"id": "eec1f5a2-5c59-4ce9-a5ae-5dc7cad70772",
|
||||||
|
"name": "port",
|
||||||
|
"displayName": "Port",
|
||||||
|
"type": "uint",
|
||||||
|
"defaultValue": 26671
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"stateTypes": [
|
||||||
|
{
|
||||||
|
"id": "354ab572-b272-4ff0-b99c-51791543a3df",
|
||||||
|
"name": "currentPower",
|
||||||
|
"displayName": "Current power",
|
||||||
|
"displayNameAction": "Set current power",
|
||||||
|
"type": "double",
|
||||||
|
"unit": "Watt",
|
||||||
|
"defaultValue": 0,
|
||||||
|
"writable": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "7a3eabcc-d124-40b2-b801-f51767400d4c",
|
||||||
|
"name": "voltagePhaseA",
|
||||||
|
"displayName": "Voltage phase A",
|
||||||
|
"displayNameAction": "Set voltage phase A",
|
||||||
|
"type": "double",
|
||||||
|
"unit": "Volt",
|
||||||
|
"defaultValue": 230,
|
||||||
|
"writable": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "4fea5d43-5d92-49f5-b045-46f471d76b00",
|
||||||
|
"name": "currentPhaseA",
|
||||||
|
"displayName": "Current phase A",
|
||||||
|
"displayNameAction": "Set current phase A",
|
||||||
|
"type": "double",
|
||||||
|
"unit": "Ampere",
|
||||||
|
"defaultValue": 0,
|
||||||
|
"writable": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "thermalProbe",
|
||||||
|
"displayName": "Mocked Thermal Probe (sonde de température)",
|
||||||
|
"id": "37e61ea3-e4b2-4d3e-bc82-66e350c29f66",
|
||||||
|
"createMethods": [
|
||||||
|
"user"
|
||||||
|
],
|
||||||
|
"interfaces": [
|
||||||
|
"temperaturesensor"
|
||||||
|
],
|
||||||
|
"paramTypes": [
|
||||||
|
{
|
||||||
|
"id": "b898397c-c291-47f9-82ec-662250b932e1",
|
||||||
|
"name": "port",
|
||||||
|
"displayName": "Port",
|
||||||
|
"type": "uint",
|
||||||
|
"defaultValue": 26681
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"stateTypes": [
|
||||||
|
{
|
||||||
|
"id": "098b3a75-59dd-4cd5-8595-9cd66fedfd84",
|
||||||
|
"name": "temperature",
|
||||||
|
"displayName": "Temperature",
|
||||||
|
"displayNameAction": "Set temperature",
|
||||||
|
"type": "double",
|
||||||
|
"unit": "DegreeCelsius",
|
||||||
|
"defaultValue": 20.0,
|
||||||
|
"writable": true
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user