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); + }); + }); }