From 92495c0e8737b6f47b893c2e5b0f5fb3e1fdbe08 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Mon, 7 Sep 2026 06:42:17 +0200 Subject: [PATCH] =?UTF-8?q?fix(dashboard):=20=C2=AB=20sans=20production=20?= =?UTF-8?q?=C2=BB=20n'est=20pas=20=C2=AB=20pas=20mesurable=20=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signalé sur appareil de nuit : tuile Autoconsommation « — · pas mesurable », sur une installation dont les quatre compteurs marchent. D'où vient le nombre : la tuile lit `DailyBalance`, pas `GetEnergyRatios`. `selfConsumptionRate` y rend `null` dès que `productionKwh <= 0` — production nulle depuis minuit, donc 0/0. Le « — » est JUSTE et ne bouge pas. Un « 0 % » affirmerait qu'aucune production n'a été autoconsommée : une phrase sur une production qui n'existe pas, soit un zéro forgé de plus. Ce qui était faux, c'est le motif écrit dessous — il accusait la mesure là où il n'y avait simplement rien à mesurer. Les deux causes d'un taux absent sont désormais séparées : - pas de bilan (échantillons, cumuls, compteur en recul) → « pas mesurable » ; - dénominateur nul → « sans production » / « sans consommation ». C'est la distinction habituelle du dépôt prise par l'autre bout : d'ordinaire on empêche une absence de se dire zéro ; ici on l'empêche d'accuser la mesure. Mesuré sur .75 au même instant : production 0 W, `selfConsumptionRate` absent de GetEnergyRatios sur trois relevés, `autonomyRate: 0` présent — la box fait la même distinction, sur la même arithmétique. Le premier jet du test asserait `find.text('0 %'), findsNothing` : il mordait sur le 0 % de l'Autonomie, qui est une mesure. Corrigé en le voyant échouer, et la raison est dans le test. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw --- lib/features/dashboard/widgets/kpi_row.dart | 39 ++++++++++- test/kpi_ratios_test.dart | 74 +++++++++++++++++++++ 2 files changed, 110 insertions(+), 3 deletions(-) diff --git a/lib/features/dashboard/widgets/kpi_row.dart b/lib/features/dashboard/widgets/kpi_row.dart index 3fa0348..88680fc 100644 --- a/lib/features/dashboard/widgets/kpi_row.dart +++ b/lib/features/dashboard/widgets/kpi_row.dart @@ -66,6 +66,30 @@ String _instantane(EnergyRatiosSnapshot r) { 'autonomie ${pc(r.autonomyRate)}'; } +/// **Pourquoi la tuile n'a pas de nombre** — et les deux raisons ne se ressemblent pas. +/// +/// Un taux absent a deux causes qui ne disent pas du tout la même chose : +/// +/// - **la box n'a pas de bilan** — moins de deux échantillons, cumuls absents, compteur en +/// recul. Là, on ne sait pas : « pas mesurable » ; +/// - **le dénominateur est nul** — aucune production depuis minuit, donc aucune fraction de +/// production à rapporter. Là, on sait parfaitement, et il n'y a **rien à mesurer**. +/// +/// Signalé sur appareil le 2026-09-07, de nuit : la tuile Autoconsommation affichait +/// « — · pas mesurable » sur une installation dont tous les compteurs marchaient. Le « — » +/// était juste — un `0 %` aurait affirmé qu'aucune production n'a été autoconsommée, une +/// phrase sur une production qui n'existe pas — mais **le motif était faux**, et c'est lui +/// qui se lit comme une panne. +/// +/// C'est la distinction habituelle de ce dépôt prise par l'autre bout : d'ordinaire on +/// empêche une absence de se dire zéro ; ici on empêche une absence d'accuser la mesure. +String? _motifAbsence(DailyBalance? b, {required bool production}) { + if (b == null) return 'pas mesurable'; + final denominateur = production ? b.productionKwh : b.consumptionKwh; + if (denominateur <= 0) return production ? 'sans production' : 'sans consommation'; + return 'pas mesurable'; +} + class KpiRow extends StatelessWidget { final EnergyData data; @@ -131,6 +155,7 @@ class KpiRow extends StatelessWidget { child: _RingTile( label: 'Autoconsommation', value: b?.selfConsumptionRate, + motifAbsence: _motifAbsence(b, production: true), color: EtmTokens.solarOf(context), ), ), @@ -139,6 +164,7 @@ class KpiRow extends StatelessWidget { child: _RingTile( label: 'Autonomie', value: b?.autonomyRate, + motifAbsence: _motifAbsence(b, production: false), color: EtmTokens.ecoOf(context), ), ), @@ -215,11 +241,18 @@ class KpiRow extends StatelessWidget { class _RingTile extends StatefulWidget { final String label; - /// `null` = **non mesurable**, et surtout pas 0 %. Un anneau vide et un anneau à zéro se + /// `null` = **pas de nombre**, et surtout pas 0 %. Un anneau vide et un anneau à zéro se /// ressemblent trop pour qu'on laisse le second dire le premier. final double? value; + + /// Ce qui s'écrit à la place, quand [value] est `null` — voir [_motifAbsence]. + final String? motifAbsence; final Color color; - const _RingTile({required this.label, required this.value, required this.color}); + const _RingTile( + {required this.label, + required this.value, + required this.color, + this.motifAbsence}); @override State<_RingTile> createState() => _RingTileState(); @@ -334,7 +367,7 @@ class _RingTileState extends State<_RingTile> alignment: Alignment.centerLeft, child: Text( widget.value == null - ? '${widget.label} · pas mesurable' + ? '${widget.label} · ${widget.motifAbsence ?? 'pas mesurable'}' : widget.label, style: EtmTokens.sans( size: 11, diff --git a/test/kpi_ratios_test.dart b/test/kpi_ratios_test.dart index 70d3dc3..2b0a53d 100644 --- a/test/kpi_ratios_test.dart +++ b/test/kpi_ratios_test.dart @@ -39,6 +39,26 @@ DailyBalance _bilan() => DailyBalance( exportKwh: 1.0, ); +/// La NUIT du 2026-09-07 sur `.75` : consommation réelle, production nulle depuis minuit. +DailyBalance _bilanDeNuit() => DailyBalance( + from: DateTime(2026, 9, 7), + to: DateTime(2026, 9, 7, 2, 30), + periodStart: DateTime(2026, 9, 7), + sampleCount: 10, + productionKwh: 0.0, + consumptionKwh: 1.4, + importKwh: 1.4, + exportKwh: 0.0, + ); + +Widget _harnessBilan(DailyBalance b) => MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: KpiRow(data: const EnergyData(), balance: b), + ), + ), + ); + Widget _harness({EnergyRatiosSnapshot? ratios}) => MaterialApp( home: Scaffold( body: SingleChildScrollView( @@ -129,4 +149,58 @@ void main() { await tester.pumpWidget(_harness()); expect(find.textContaining('Instantané'), findsNothing); }); + + + group('un taux sans dénominateur — le « — » est juste, le MOTIF ne l\'était pas', () { + // Signalé sur appareil le 2026-09-07 : « Autoconsommation — · pas mesurable » de nuit, + // sur une installation dont les quatre compteurs marchaient. Deux causes distinctes + // portaient la même phrase, et celle qui s'affichait accusait la mesure. + testWidgets('sans production, la tuile dit « sans production »', (tester) async { + await tester.binding.setSurfaceSize(const Size(1000, 2000)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget(_harnessBilan(_bilanDeNuit())); + await tester.pumpAndSettle(); + + expect(find.textContaining('Autoconsommation · sans production'), findsOneWidget); + // Le contrôle qui MORD : l'ancien motif ne doit plus être là pour cette tuile. + expect(find.textContaining('Autoconsommation · pas mesurable'), findsNothing); + // Et surtout PAS un zéro sur CETTE tuile : « 0 % » affirmerait qu'aucune production + // n'a été autoconsommée, une phrase sur une production qui n'existe pas. Le tiret y + // est, et il y est une seule fois — l'autre tuile, elle, a bien un nombre. + // + // ⚠️ Ne pas écrire `find.text('0 %'), findsNothing` : le 0 % de l'Autonomie est une + // MESURE et le contrôle mordrait sur elle. Première rédaction de ce test, corrigée + // en la voyant échouer. + expect(find.text('—'), findsOneWidget); + }); + + testWidgets('l\'autonomie, elle, a son dénominateur et vaut 0 %', (tester) async { + await tester.binding.setSurfaceSize(const Size(1000, 2000)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget(_harnessBilan(_bilanDeNuit())); + await tester.pumpAndSettle(); + + // Tout vient du réseau : 0 % est une MESURE, pas un trou. Les deux tuiles sont + // voisines et leurs deux « rien » sont de natures opposées — c'est le cas qui + // interdit de traiter les taux ensemble. + expect(find.text('0 %'), findsOneWidget); + expect(find.textContaining('Autonomie · '), findsNothing); + }); + + testWidgets('sans bilan du tout, le motif reste « pas mesurable »', (tester) async { + await tester.binding.setSurfaceSize(const Size(1000, 2000)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: KpiRow(data: const EnergyData(), balance: null), + ), + ), + )); + await tester.pumpAndSettle(); + + expect(find.textContaining('Autoconsommation · pas mesurable'), findsOneWidget); + expect(find.textContaining('Autonomie · pas mesurable'), findsOneWidget); + }); + }); }