Patrick Schurig 119cffc745 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.
2026-09-05 08:52:24 +02:00

149 lines
9.2 KiB
Markdown

# INVENTAIRE À FROID DU MOCK — ce qu'il rend structurellement introuvable
*(Fait le 2026-09-01, après trois occurrences en trois jours d'un cas réel impossible à
reproduire. Ce n'est plus un incident, c'est une propriété du banc d'essai.)*
## Pourquoi cet inventaire
Trois fois cette semaine, un défaut réel s'est révélé **inatteignable par la suite** :
| Occurrence | Ce qui était introuvable | Comment on l'a su |
|---|---|---|
| Correctif amont `calculateAllowanceAmpere` | un compteur publiant les courants **sans** les puissances par phase | le mock pose `currentPhaseB = currentPowerPhaseB / 230`, cohérent **par construction** |
| Test `command` sous `source: none` | une charge dont rien ne mesure la puissance | **toutes** les classes pilotables portent `currentPower` |
| Test `ECO_FLOOR_*` en régime `unmeasurable` | une borne sans `sessionEnergy` | il a fallu **ajouter** l'état à une seule classe pour avoir les deux cas |
Le point commun : **le mock est plus homogène que la réalité.** Il a été construit pour faire
marcher des scénarios, pas pour représenter la diversité du parc — et une suite verte ne dit alors
rien sur les cas qu'il ne sait pas produire.
## Les classes, et ce qu'elles portent
| Classe | `currentPower` | `totalEnergyConsumed` | `sessionEnergy` |
|---|---|---|---|
| `meter` | ✅ | ✅ | — |
| `charger` | ✅ | ✅ | ❌ |
| `chargerPhaseSwitching` | ✅ | ✅ | ✅ *(ajouté le 2026-08-31)* |
| `simpleCharger` | ❌ | ❌ | ❌ |
| `powerSwitch` | ✅ | ❌ | — |
| `etmVariableLoad` | ✅ *(`currentPowerW`)* | ❌ | — |
| `energyStorage` | ✅ | ❌ | — |
| `car`, `notification` | ❌ | ❌ | — |
**Ce que la table donne, et c'est la bonne nouvelle** : `sessionEnergy` et `totalEnergyConsumed`
sont désormais **discriminants** — il existe des classes avec et sans. Les régimes `measured` /
`unmeasurable` et les causes `noSessionEnergy` / `meterWithoutEnergy` sont donc exerçables.
## Ce qui reste STRUCTURELLEMENT introuvable
**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.~~ — OUVERT le 2026-09-03.** `voltagePhaseX` et
`currentPhaseX` sont désormais réglables **indépendamment** des puissances, par des paramètres
OPTIONNELS — sans eux, le comportement dérivé d'origine est conservé et aucun scénario existant
ne bouge.
> **Et l'ouverture a corrigé une erreur de RAISONNEMENT, pas seulement comblé un trou.** Le
> premier test de `LM-1210-a` attendait qu'une phase en export donne une marge *supérieure* au
> plafond — c'est ce que le design affirmait. Il a échoué : `calculateAllowanceAmpere()` part du
> plafond et ne fait que des `qMin`, donc la marge y est **écrêtée**. Physiquement juste — l'export
> ne permet pas de tirer plus que le disjoncteur — mais la phrase du design était fausse. **La
> conclusion tenait, la raison non**, et rien ne pouvait le révéler tant que le déséquilibre était
> infabricable.
**3. ~~`temperature`.~~ — OUVERT le 2026-09-05.** La classe `thermalProbe` porte l'état, et
`testThermalProbeFeedsTelemetry` exerce enfin la branche « sonde présente » de
`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.**
`testWorstPhaseBindsWhenPhasesAreUnbalanced` exerce enfin `LM-1210-a` : phase B saturée à 30 A
sous un plafond de 25 pendant que A et C exportent, et la maison **exporte globalement**. La
marge retenue est celle de B — négative — et non celle du total, qui rassurerait à tort.
Ce manque était nommé **deux fois par deux chemins indépendants** — cet inventaire et le balayage
des contrôles — avant d'être comblé. C'est ce qui lui a donné sa priorité.
## La règle qui en sort
> **Avant d'écrire un test qui exerce une ABSENCE, vérifier que le mock peut la produire.**
> Une assertion sur un cas inatteignable ne passe pas : elle **ne s'exécute pas**, ce qui est
> pire — elle compte comme verte.
>
> Le symptôme se reconnaît : **la contre-épreuve ne mord pas**. C'est le corollaire fin
> d'`AGENTS.md` appliqué au banc d'essai plutôt qu'au code.
**Et la parade tient en une ligne de test** : quand on itère pour trouver des cas, **compter
combien on en a examinés**, et échouer si le compte est nul. C'est ce qui a rattrapé le test de
`command`, et ce qui garde celui de la mesurabilité.
**5. ~~Un compteur d'énergie FIGÉ.~~ — OUVERT le 2026-09-04.** `totalEnergyConsumed` n'était
écrit par personne : il valait sa valeur par défaut, indéfiniment. **Un compteur figé est un
compteur qui n'existe pas pour tout ce qui l'intègre** — `deliveredWh` restait à 0, `remainingWh`
à 4 000, et l'étalement adaptatif n'était **jamais exercé**. Le comportement se confondait avec
`unmeasurable` pour une raison entièrement différente et bénigne.
Le mock intègre désormais `currentPower` dans le cumul, une fois par seconde. **En export il ne
recule pas — il ne bouge pas** : un compteur de consommation est monotone, et le faire reculer
produirait un cas que le terrain ne produit pas, que la garde de sémantique traiterait comme une
anomalie.
**Et `LM-1014-b` s'y applique, question posée et répondue** : `totalEnergyConsumed` est déclaré
`cached`, donc il **continue** au redémarrage du mock. Repartir de zéro ferait reculer un cumul
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
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é
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 ».
2. ~~**Un compteur sans `totalEnergyConsumed`** en tant que classe déclarée.~~ — **FAIT le
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
amont ailleurs qu'en inversant la fonction.
> Aucune de ces trois n'est urgente. Ce qui l'est, c'est de **savoir qu'elles manquent** : sans
> cette liste, chaque lot repaiera le même temps à découvrir qu'un cas est introuvable.