diff --git a/debian-qt5/changelog b/debian-qt5/changelog index 154e4b4..c415865 100644 --- a/debian-qt5/changelog +++ b/debian-qt5/changelog @@ -1,3 +1,34 @@ +powersync-energy-plugin-nymea (1.15.2+etm50) trixie; urgency=medium + + * BALAYAGE — « qu'est-ce que ce contrôle aurait attrapé s'il avait échoué ? » passée à toute la + suite. Ce n'est pas une liste de tests à supprimer, c'est la liste de ce qu'on croyait + vérifié et qui ne l'était pas (docs/BALAYAGE_CONTROLES_20260903.md). + * ASSERTIONS CONDITIONNELLES DANS UNE BOUCLE : 0. La seule qui existait a été corrigée le + 2026-09-01 ; l'outil le confirme et le surveillera. + * IDENTITÉS AUTO-RÉFÉRENTIELLES : 10 relevées, UNE seule vaine. Le motif signale, la lecture + tranche. `authorisedW − committedW == remainingW` relisait une affectation d'UNE LIGNE : + retirée, avec sa raison écrite à sa place — et la distinction d'avec l'identité du BUDGET, + qui croise `remainingSurplusW` filé le long de la cascade avec `allocatedW` accumulé en TROIS + sites, et qui mérite donc d'être assertée. Supprimer sans dire pourquoi aurait invité + quelqu'un à la remettre. + * LA MOITIÉ QUI COMPTE — l'index annonçait 47 règles « spécifiées seules ». Il mesurait la + CITATION, pas la couverture : DOUZE étaient couvertes par un test qui ne les nommait pas. + Citées par un test : 22 → 40. Spécifiées seules : 47 → 31. Les liens sont désormais + vérifiables par la machine au lieu de reposer sur la mémoire de qui a écrit quoi. + * Sur les 31 restantes, 23 appartiennent à des familles NON IMPLANTÉES (sonde thermique, + transport §14, optimiseur externe) : écrire leur test poserait un invariant sur du code que + rien n'émet. Six sont du cadrage mal classé. Et UNE SEULE manque vraiment — LM-1210-a, la + pire phase faute de masque, qu'aucun test n'exerce PARCE QUE le mock ne sait pas produire une + triphasée déséquilibrée. Même angle mort que l'inventaire, nommé deux fois désormais. + * `tools/audit-controles.py` pérennise les deux motifs. Il ne remplace pas la question, il la + pose là où elle se pose souvent. + * Le résultat le plus utile n'est pas le défaut trouvé, c'est le CHIFFRE CORRIGÉ : on croyait + 47 règles non couvertes, il y en avait 31, dont une seule qui manque vraiment. + * Suite : simulation 62/62, loadmodel 22/22, charging 48/48 identique à la référence, + spotmarket 32/32, doxygen 0 avertissement. + + -- Patrick Schurig Thu, 03 Sep 2026 10:00:00 +0200 + powersync-energy-plugin-nymea (1.15.2+etm49) trixie; urgency=medium * CORRECTIF — sur `measured → unmeasurable → measured` DANS LA MÊME PÉRIODE, `deliveredWh` diff --git a/docs/BALAYAGE_CONTROLES_20260903.md b/docs/BALAYAGE_CONTROLES_20260903.md new file mode 100644 index 0000000..605b27f --- /dev/null +++ b/docs/BALAYAGE_CONTROLES_20260903.md @@ -0,0 +1,78 @@ +# BALAYAGE — « qu'est-ce que ce contrôle aurait attrapé s'il avait échoué ? » + +*(2026-09-03. La question passée à toute la suite existante. Ce qui suit n'est pas une liste de +tests à supprimer : c'est la liste de **ce qu'on croyait vérifié et qui ne l'était pas**.)* + +## Pourquoi ce balayage + +Trois défauts de cette classe ont été rencontrés **sans les chercher**, sous trois formes : + +| Forme | Cas réel | +|---|---| +| La contre-épreuve **inerte** | perturber `deliveredWh` sous `unmeasurable`, où il vaut 0 — la perturbation ne pouvait rien changer | +| L'identité qui se **referme sur elle-même** | `Σ levels[].targetW == estimatedPowerW` : deux termes de la même source | +| Le test qui échoue **à côté** | l'horodatage « après échéance » visait la MÊME échéance — rien ne basculait | + +Trois formes, **un seul défaut** : le voyant est vert et n'a rien mesuré. + +## Résultat 1 — les contrôles qui ne contrôlent rien + +**Assertions conditionnelles dans une boucle : 0.** La seule qui existait — « sans mesure, pas de +`command` » — a été corrigée le 2026-09-01. L'outil (`tools/audit-controles.py`) le confirme et +le surveillera. + +**Identités auto-référentielles : 10 relevées, 1 vaine.** Le motif signale, il ne juge pas — la +lecture tranche, et le résultat est très inégal : + +| Assertion | Verdict | +|---|---| +| `surplus + recrédit − alloué == restant` | **contrôle** — croise `remainingSurplusW` filé le long de la cascade avec `allocatedW` accumulé en **trois sites distincts**. Attraperait un site qui décrémente sans accumuler | +| `lockMin == lockMax`, `minState == maxState` | **contrôlent** — champs différents, ils épinglent le gel d'une charge | +| `mapping.size() == levels.size()` | **contrôle** — une dérivation qui produirait un compte différent | +| `authorisedW − committedW == remainingW` | **NE CONTRÔLE RIEN** — le moteur l'établit sur **une ligne** ; l'assertion relisait l'affectation | +| `remainingWh == targetWh` sous `unmeasurable` | **faible, et documenté** — corollaire de `delivered == false`, pas une garde indépendante | +| `expectedW == observedW` sous `device` | **faible, et délibéré** — le mock renvoie sa consigne ; l'assertion documente le piège plutôt qu'elle ne contrôle | + +**Action** : l'assertion vaine est retirée, **avec sa raison écrite à sa place** — et la +distinction d'avec l'identité du budget, qui, elle, mérite d'être assertée. Supprimer sans dire +pourquoi aurait invité quelqu'un à la remettre. + +## Résultat 2 — les invariants que personne ne couvrait (la moitié qui compte) + +L'index des règles annonçait **47 règles « spécifiées seules »** — ni code, ni test. Le balayage +montre que **l'index mesure la CITATION, pas la couverture** : douze de ces règles étaient +couvertes par un test qui ne les nommait pas. + +| | avant | après | +|---|---|---| +| citée par un test | 22 | **40** | +| spécifiée seule | 47 | **31** | + +Les liens sont désormais **vérifiables par la machine** au lieu de reposer sur la mémoire de qui a +écrit quoi. C'est la doctrine du dépôt appliquée à elle-même : *quelle machine rejouera cette +décision ?* + +**Sur les 31 restantes, 23 appartiennent à des familles NON IMPLANTÉES** — sonde thermique +(`ECS-5xx`/`6xx`), transport réseau du §14 (`LM-14xx`), optimiseur externe (`LM-4xx`/`5xx`), +ancien lot ECS. Elles sont légitimement sans porteur : écrire leur test aujourd'hui poserait un +invariant sur un code que rien n'émet. + +**Restent huit règles du lot courant sans porteur**, et elles se répartissent en deux tas : + +- **Cadrage mal classé** — `LM-1000`, `LM-1001`, `LM-1005`, `LM-1006`, `LM-1010`, `LM-1014` : + des règles de forme et de doctrine, dont le porteur naturel est la spec elle-même. À reclasser + en `cadrage`, ce qui est une correction d'index, pas un manque de test. +- **Un vrai manque, assumé** — `LM-1210-a` (la marge retenue est celle de la pire phase, faute de + masque de phases). Aucun test ne l'exerce **parce que le mock ne sait pas produire une + triphasée déséquilibrée** (cf. `INVENTAIRE_MOCK.md`, manque n°4). C'est le même angle mort, + et il est maintenant nommé deux fois. + +## Ce que le balayage a coûté et rapporté + +Coût : une heure. Rapport : **une assertion vaine retirée**, **dix-huit invariants dont la +couverture est passée de crue à démontrée**, et **un outil** qui repose la question à chaque +passage de `ci-quality`. + +> **Le résultat le plus utile n'est pas le défaut trouvé, c'est le chiffre corrigé.** On croyait +> 47 règles non couvertes ; il y en avait 31, dont 23 légitimes et une seule qui manque vraiment. +> Savoir lesquelles change ce qu'on surveille. diff --git a/tests/auto/loadmodel/testloadmodel.cpp b/tests/auto/loadmodel/testloadmodel.cpp index 12d9d70..efd9118 100644 --- a/tests/auto/loadmodel/testloadmodel.cpp +++ b/tests/auto/loadmodel/testloadmodel.cpp @@ -540,6 +540,11 @@ void TestLoadModel::testRankOriginFollowsTheRankNotTheWrite() /*! * \brief [§10 étape D] L'étalement uniforme, et le durcissement qu'il faut savoir DIRE. * + * \par Règles couvertes + * \rule{LM-1013-b-i} (recalculé à chaque cycle), \rule{LM-1013-b-ii} (le durcissement se dit), + * \rule{LM-1013-b-iii} (point de non-retour nommé), \rule{LM-1013-b-iv} (l'heure quotidienne + * vise la prochaine occurrence). + * * \par Ce que la mesure a montré avant que rien ne soit asserté * L'étape D est la seule des quatre qui change le comportement, et aucun scénario existant ne * portait d'obligation éco — `LoadContext::needs` n'était rempli par aucun adaptateur. Il n'y diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index dde060e..b555eb5 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -6434,6 +6434,11 @@ void Simulation::testARefusedStateIsDistinguishableFromAnApplied() /*! * \brief [Étape 4] L4 déleste une PAC en la faisant CHANGER D'ÉTAT, et dit qu'il a forcé. * + * \par Règles couvertes + * \rule{LM-1211} (délestage en ordre inverse du rang), \rule{LM-1211-a} (un seul commandeur par + * organe), \rule{LM-1211-b} (le franchissement se CONSTATE), \rule{LM-1211-d} (délester peut + * être changer d'état). + * * \par Le premier cas où délester ne veut pas dire retirer des watts * Une PAC ne se module pas. Le bornage de la cascade (\rule{LM-1210}) ne peut rien contre une * PAC déjà en état 4 : il empêche d'ALLOUER au-delà du plafond, il ne retire rien à personne. @@ -6658,9 +6663,19 @@ void Simulation::testDrawAuthorisationSurvivesTheBatteryReserve() QCOMPARE(qRound(nominal.draw.remainingW), 3450); QCOMPARE(nominal.draw.bindingCode, QString("connection")); QCOMPARE(nominal.draw.perPhaseBound, true); - // L'identité, qui est ce qui rend la charge utile vérifiable par un client. - QCOMPARE(qRound(nominal.draw.authorisedW - nominal.draw.committedW), - qRound(nominal.draw.remainingW)); + // L'identité `authorisedW − committedW == remainingW` N'EST PAS ASSERTÉE ICI, et c'est + // délibéré : le moteur l'établit sur UNE LIGNE (`rulebasedscheduler.cpp`, `draw.remainingW = + // draw.authorisedW - draw.committedW`). L'asserter reviendrait à relire cette affectation — + // les deux membres viennent de la même source, l'identité se referme sur elle-même, et le + // contrôle n'attraperait rien. + // + // Ce qui contrôle réellement, ce sont les TROIS attentes littérales ci-dessus : elles + // viennent du scénario, pas du moteur. Relevé au balayage du 2026-09-03. + // + // ⚠️ À ne pas confondre avec l'identité du BUDGET (`surplus + recrédit − alloué == + // restant`), qui, elle, croise DEUX calculs indépendants — `remainingSurplusW` filé le long + // de la cascade contre `allocatedW` accumulé en trois sites distincts. Celle-là attrape un + // site qui décrémenterait sans accumuler, et mérite d'être assertée. // 3. SOUS LA RÉSERVE — le cœur du test. Le surplus est ANNULÉ, l'autorisation INTACTE. const Plan sousReserve = planifier(true, true); @@ -6686,6 +6701,12 @@ void Simulation::testDrawAuthorisationSurvivesTheBatteryReserve() /*! * \brief [§10 étape C] L'avancement est une DIFFÉRENCE depuis une base, et le régime se publie. * + * \par Règles couvertes + * \rule{LM-1012-b} (l'avancement est une différence), \rule{LM-1012-b-i} (base à l'ouverture de + * l'obligation), \rule{LM-1012-b-ii} (recapture sur recul franc), \rule{LM-1014-c} (le cumul + * survit au changement de RÉGIME), \rule{LM-1014-c-i} (remise à zéro à la période), + * \rule{LM-1014-c-ii} (\c measuredSince garde le premier instant). + * * \par Le piège, mesuré sur le banc le 2026-08-31 * `sessionEnergy` est l'ACCUMULATEUR du plugin V2C, pas un compteur remis à zéro pour nous : * l'écart avec `chargeEnergy` est constant au dix-millième (7,2044 kWh sur deux échantillons à @@ -6693,6 +6714,10 @@ void Simulation::testDrawAuthorisationSurvivesTheBatteryReserve() * borne. **Le lire directement ferait croire toute obligation tenue de 7,2 kWh dès le premier * cycle** — un nombre crédible et faux que rien ne signalerait. * + * \par Règle couverte + * \rule{LM-1012-c} — le RÉGIME d'avancement se publie, porté par le niveau, et + * \c deliveredWh est absent sous \c unmeasurable plutôt que nul. + * * \par Les deux questions que la base pose, et leurs réponses * 1. **Quand est-elle prise ?** À l'ouverture de l'OBLIGATION, jamais au démarrage de nymead : * sinon un redémarrage en pleine session réinitialise l'avancement sur une valeur déjà @@ -6854,6 +6879,11 @@ void Simulation::testEcoProgressBaseAndRegime() /*! * \brief [§10 étape D] La passe ÉCO achète, et le motif dit la TRAJECTOIRE. * + * \par Règles couvertes + * \rule{LM-1002} (deux passes sur la même liste ordonnée), \rule{LM-1007} (une charge sans + * obligation ne porte pas d'entrée « eco » — l'absence EST l'information), \rule{LM-1013-b} + * (l'étalement uniforme), \rule{LM-1210} (l'autorisation borne la cascade). + * * \par Deux passes, une commande * Même liste, même ordre ; ce qui change est la BORNE — l'autorisation de soutirage pour l'éco, * le reliquat de surplus pour le confort. Les deux niveaux sont publiés, mais **une seule diff --git a/tools/audit-controles.py b/tools/audit-controles.py new file mode 100644 index 0000000..43aa367 --- /dev/null +++ b/tools/audit-controles.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""Cherche les CONTRÔLES QUI NE CONTRÔLENT RIEN. + +La question, posée à chaque assertion : **qu'aurait-elle attrapé si elle avait échoué ?** +S'il n'y a pas de réponse, elle ne contrôle rien — et le voyant vert ment. + +Deux motifs, tous deux rencontrés en vrai : + + 1. ASSERTION CONDITIONNELLE DANS UNE BOUCLE. Si la condition n'est jamais vraie, l'assertion + ne s'exécute pas et le test passe. Vu le 2026-09-01 : « sans mesure, pas de `command` » + n'avait AUCUNE charge à examiner, et la garde retirée le test passait toujours. + Parade : compter les cas examinés, échouer si le compte est nul. + + 2. IDENTITÉ QUI SE REFERME SUR ELLE-MÊME. Quand les deux membres viennent de la même source, + l'identité est vraie par construction et rassure là où rien ne protège. Vu le 2026-09-03 : + `authorisedW − committedW == remainingW` relisait l'affectation d'une seule ligne. + + ⚠️ Toutes les identités auto-référentielles ne sont pas vaines : celle du BUDGET croise + `remainingSurplusW`, filé le long de la cascade, avec `allocatedW` accumulé en trois sites + distincts. Le motif signale, il ne juge pas — c'est à la lecture de trancher. + +Cet outil ne remplace pas la question, il la pose là où elle se pose souvent. +""" +import re +from pathlib import Path +ASSERT = r'(?:QCOMPARE|QVERIFY2?|QTRY_COMPARE|QTRY_VERIFY2?)' + +def fonctions(txt): + for m in re.finditer(r'\nvoid\s+\w+::(\w+)\(\)\s*\n\{', txt): + deb = m.end(); prof, i = 1, deb + while i < len(txt) and prof: + if txt[i] == '{': prof += 1 + elif txt[i] == '}': prof -= 1 + i += 1 + yield m.group(1), txt[deb:i], txt[:m.start()].count('\n')+2 + +cond, miroir = [], [] +for f in Path('tests/auto').rglob('*.cpp'): + if 'mock' in str(f) or 'experience' in str(f): continue + txt = f.read_text(encoding='utf-8') + for nom, corps, ligne in fonctions(txt): + if nom.endswith('_data'): continue + # MOTIF 1 — assertion sous un `if` DANS une boucle : si la condition n'est jamais + # vraie, l'assertion ne s'exécute pas et le test passe sans rien vérifier. + for m in re.finditer(r'for\s*\([^)]*\)\s*\{(.{0,900}?)\n\s*\}', corps, re.S): + bloc = m.group(1) + if re.search(r'\n\s*if\s*\([^)]*\)[^;{]*\n?\s*' + ASSERT, bloc): + garde = re.search(r'\b(examin|vus|compte|trouv)\w*\b', corps, re.I) + cond.append((f, ligne, nom, "gardé" if garde else "NON GARDÉ")) + break + # MOTIF 2 — comparaison dont les deux membres partent de la MÊME racine. + for m in re.finditer(r'QCOMPARE\(\s*([^,]+?)\s*,\s*([^;]+?)\s*\)\s*;', corps): + a, b = m.group(1), m.group(2) + ra = re.match(r'\s*(?:qRound\()?\s*([A-Za-z_][\w]*)', a) + rb = re.match(r'\s*(?:qRound\()?\s*([A-Za-z_][\w]*)', b) + if ra and rb and ra.group(1) == rb.group(1) and '.' in a and '.' in b: + miroir.append((f, ligne, nom, m.group(0)[:96])) + +print("=== MOTIF 1 — assertion conditionnelle dans une boucle : %d ===" % len(cond)) +print(" Si la condition n'est jamais vraie, rien ne s'exécute et le test PASSE.") +for f, l, n, g in cond: print(" [%s] %s:%d %s" % (g, f, l, n)) +print() +print("=== MOTIF 2 — les deux membres viennent de la MÊME racine : %d ===" % len(miroir)) +print(" L'identité se referme sur elle-même : vraie par construction.") +for f, l, n, t in miroir: print(" %s:%d %s\n %s" % (f, l, n, t))