fix(dashboard): « sans production » n'est pas « pas mesurable »

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
This commit is contained in:
Patrick Schurig 2026-09-07 06:42:17 +02:00
parent b843d8936a
commit 92495c0e87
2 changed files with 110 additions and 3 deletions

View File

@ -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,

View File

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