fix(écran): trois défauts trouvés sur appareil, dont un de forme et un de moi

Passage sur le Redmi contre `.75`. Aucun des trois n'était visible en test.

1. LA PRODUCTION S'AFFICHAIT NÉGATIVE — « -8.9 kW · Solaire » sous 9 kW de
   soleil. `currentPowerProduction` est publié négatif par nymea et le service
   garde les signes bruts par politique ; la carte imprimait la valeur telle
   quelle. Ce qui rend le défaut net plutôt qu'opinable : la même carte
   normalisait DÉJÀ le réseau et la batterie par `.abs()` — la branche solaire
   était la seule oubliée, donc un oubli et non une convention.

2. LE LIBELLÉ SE COUPAIT AU MILIEU DU MOT — « Autoconsommat / ion », alors que
   le commentaire du code disait vouloir l'éviter. La cause n'est pas la
   longueur mais l'ÉCHELLE du texte : police système au-dessus de 100 %, et
   `maxLines: 2` ne protège de rien puisque Flutter coupe un mot long sans
   espace, sans même afficher l'ellipse. `FittedBox(scaleDown)` garde le mot
   entier et ne fait rien quand la place suffit.

3. MA LIGNE DES RATIOS RÉPÉTAIT LES TUILES — « Instantané depuis 09:23 —
   autoconsommation 52 %, autonomie 54 % » sous des tuiles affichant 52 % et
   54 %. Les deux chemins étaient corrects et les valeurs coïncidaient vraiment
   (bilan 51,8/54,1 sur 00:00→16:45, box 51,9/53,7 depuis 09:23). Rien de faux,
   et illisible quand même : je déclenchais sur l'ORIGINE, alors qu'un lecteur
   compare des NOMBRES. La ligne sort désormais quand l'origine diffère ET que
   l'écart dépasse 2 points ; le relevé du jour est devenu un test.

Vérifié à l'écran après correction : le libellé tient sur une ligne, la ligne des
ratios se tait. Le signe, lui, N'A PAS pu être revu — la production du banc était
retombée à 0 W à 17:03, et `abs(0) == 0`. Une correction qu'on ne peut pas voir
demande une garde qui se voit : `flux_signe_test.dart` la pose.

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-05 17:07:20 +02:00
parent 763df68a2a
commit b3000ca2d5
4 changed files with 140 additions and 5 deletions

View File

@ -346,7 +346,13 @@ class _NodeLayerState extends State<_NodeLayer>
icon: Icons.wb_sunny_rounded,
color: EtmTokens.solarOf(context),
label: 'Solaire',
value: '${(d.pvPower / 1000).toStringAsFixed(1)} kW',
// ⚠️ `currentPowerProduction` est publié NÉGATIF par nymea (-8 980 W =
// 8,98 kW produits), et le service garde les signes bruts par politique.
// L'AFFICHAGE, lui, doit dire ce qu'un client comprend : une production est
// une quantité positive. Vu sur appareil le 2026-09-05, « -8.9 kW Solaire »
// sous un soleil de 9 kW — la carte normalisait déjà le réseau et la
// batterie par `.abs()`, la branche solaire était la seule oubliée.
value: '${(d.pvPower.abs() / 1000).toStringAsFixed(1)} kW',
tappable: true,
),
),

View File

@ -25,6 +25,34 @@ import '../painters/ring_painter.dart';
/// corrompus, ou compteur en recul. Les tuiles affichent alors « — » et le disent. Elles
/// n'affichent **jamais zéro** : « rien produit » et « on ne sait pas » sont deux états
/// différents, et à 3 h du matin le premier est vrai sans que le second le soit.
/// **La ligne ne s'affiche que si elle DIT quelque chose.**
///
/// Première version : elle sortait dès que l'origine différait du début de journée. Vu sur
/// appareil le 2026-09-05, ça donnait ceci — tuiles **52 % / 54 %**, et juste dessous
/// « Instantané depuis 09:23 — autoconsommation **52 %**, autonomie **54 %** ». Les mêmes
/// deux nombres, écrits deux fois.
///
/// Les valeurs coïncidaient pour de bon : bilan sur 00:00→16:45 = 51,8 / 54,1, box depuis
/// 09:23 = 51,9 / 53,7. Rien n'était faux — et la ligne était quand même illisible, parce
/// qu'**un lecteur compare des nombres, pas des origines**. Devant deux fois « 52 % », il
/// doit raisonner pour découvrir que les fenêtres diffèrent : exactement ce que la ligne
/// existait pour lui épargner.
///
/// Elle sort donc quand l'origine diffère **et** que l'écart se voit. Sous le seuil, les
/// tuiles suffisent — et une ligne permanente cesse d'être lue, comme un avertissement
/// permanent.
///
/// Sans bilan, elle sort quand même : c'est alors le seul chiffre disponible.
bool _ligneUtile(EnergyRatiosSnapshot? r, DailyBalance? b) {
if (r == null || !r.baselineRepriseEnJournee) return false;
if (b == null) return true;
double ecart(double? x, double? y) =>
(x == null || y == null) ? double.infinity : (x - y).abs();
const seuil = 2.0; // points de pourcentage — sous ça, l'arrondi affiché est identique
return ecart(r.selfConsumptionRate, b.selfConsumptionRate) >= seuil ||
ecart(r.autonomyRate, b.autonomyRate) >= seuil;
}
/// La phrase des ratios de la box — **avec leur origine, puisqu'elle diffère**.
///
/// Appelée seulement quand la baseline a été reposée après le début de journée : sans ça,
@ -116,7 +144,7 @@ class KpiRow extends StatelessWidget {
),
],
),
if (ratios?.baselineRepriseEnJournee ?? false) ...[
if (_ligneUtile(ratios, b)) ...[
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.only(left: 2),
@ -291,7 +319,20 @@ class _RingTileState extends State<_RingTile>
),
),
const SizedBox(height: 3),
Text(
// ⚠️ **La coupure se fait AU MILIEU du mot quand le texte est agrandi.**
// Vu sur appareil le 2026-09-05, réglage de police système au-dessus de
// 100 % : « Autoconsommat / ion ». `maxLines: 2` ne protège de rien —
// Flutter coupe un mot long sans espace plutôt que de déborder, et
// l'ellipse ne s'affiche même pas. Le défaut n'est donc pas la longueur du
// libellé mais la mise à l'échelle, et il reviendra sur n'importe quel mot
// à la première langue plus verbeuse.
//
// `scaleDown` garde le mot ENTIER en réduisant ce qu'il faut, et ne touche
// à rien quand la place suffit — donc aucun effet à échelle normale.
FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
widget.value == null
? '${widget.label} · pas mesurable'
: widget.label,
@ -299,10 +340,9 @@ class _RingTileState extends State<_RingTile>
size: 11,
color: EtmTokens.mutedOf(context),
),
// Deux lignes plutôt qu'un mot coupé : « Autoconsomm… » ne se lit pas,
// et la tuile a la hauteur pour le porter entier.
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
),

48
test/flux_signe_test.dart Normal file
View File

@ -0,0 +1,48 @@
/// **Une production ne s'affiche pas en négatif.**
///
/// Vu sur appareil le 2026-09-05 à 16:52 : « **-8.9 kW · Solaire** » sous un soleil de
/// 9 kW. `Energy.GetPowerBalance` publiait `currentPowerProduction: -8980.4` — nymea donne
/// la production en négatif, et `NymeaService` garde les signes bruts par politique (§7.1).
/// La carte imprimait donc la valeur telle quelle.
///
/// Ce qui rend le défaut net plutôt qu'opinable : **la même carte normalisait déjà** le
/// réseau et la batterie par `.abs()`. La branche solaire était la seule oubliée — donc ce
/// n'était pas une convention d'affichage assumée, c'était un oubli.
///
/// Ce test existe parce que l'écran ne pouvait plus le montrer : à 17:03 la production du
/// banc était retombée à 0 W, et `abs(0) == 0`. Une correction qu'on ne peut pas voir
/// demande une garde qui, elle, se voit.
library;
import 'package:etm_powersync_app/features/dashboard/widgets/energy_flow_card.dart';
import 'package:etm_powersync_app/models/energy_data.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
Future<void> _poser(WidgetTester tester, EnergyData d) async {
await tester.binding.setSurfaceSize(const Size(1000, 1600));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(
MaterialApp(home: Scaffold(body: EnergyFlowCard(data: d))));
await tester.pump(const Duration(seconds: 2));
}
void main() {
testWidgets('la production publiée NÉGATIVE s\'affiche positive', (tester) async {
// La charge utile réelle du 2026-09-05 16:52.
await _poser(tester, const EnergyData(pvPower: -8980.4, homePower: 3249.4));
// -8 980,4 W -> 8,98 kW -> « 9.0 kW » à une décimale. (Sur appareil la valeur de
// l'instant donnait « -8.9 kW » ; c'est le SIGNE qui est en cause, pas l'arrondi.)
expect(find.text('9.0 kW'), findsOneWidget);
expect(find.text('-9.0 kW'), findsNothing,
reason: 'un client lirait « mes panneaux consomment 9 kW »');
});
testWidgets('zéro reste zéro — et ne prouve rien', (tester) async {
// L'état du banc à 17:03, celui qui a rendu la correction invisible à l'écran.
await _poser(tester,
const EnergyData(pvPower: 0, homePower: 3250, gridPower: 3250));
expect(find.text('0.0 kW'), findsOneWidget);
});
}

View File

@ -82,6 +82,47 @@ void main() {
expect(find.textContaining('Instantané depuis'), findsNothing);
});
testWidgets('les deux coïncident : la ligne se TAIT — vu sur appareil', (tester) async {
await tester.binding.setSurfaceSize(const Size(1000, 2000));
addTearDown(() => tester.binding.setSurfaceSize(null));
// Relevé du 2026-09-05 16:52 : bilan 51,8 / 54,1 sur 00:00→16:45, box 51,9 / 53,7
// depuis 09:23. L'origine diffère bien, mais les nombres non — et la ligne écrivait
// « 52 %, 54 % » juste sous des tuiles qui affichaient déjà 52 % et 54 %.
final coincide = EnergyRatiosSnapshot.fromJson({
'autonomyRate': 53.67,
'selfConsumptionRate': 51.92,
'periodStartedAt': 1788559200,
'measuredSince': 1788592995,
'timeZone': 'Europe/Paris',
});
await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: SingleChildScrollView(
child: KpiRow(
data: const EnergyData(),
// 8 kWh produits / 1 exporté -> 87,5 % ; 12 consommés / 5 importés -> 58,3 %.
// On force ici un bilan proche des valeurs de la box pour éprouver le seuil.
balance: DailyBalance(
from: DateTime(2026, 9, 5, 0, 15),
to: DateTime(2026, 9, 5, 16, 45),
periodStart: DateTime(2026, 9, 5),
sampleCount: 72,
productionKwh: 59.89,
consumptionKwh: 57.31,
importKwh: 26.31,
exportKwh: 28.89,
),
ratios: coincide,
),
),
),
));
await tester.pumpAndSettle();
expect(find.textContaining('Instantané depuis'), findsNothing,
reason: 'répéter les mêmes nombres oblige le lecteur à raisonner pour '
'découvrir que les fenêtres diffèrent');
});
testWidgets('box antérieure : aucune ligne, aucune supposition', (tester) async {
await tester.binding.setSurfaceSize(const Size(1000, 2000));
addTearDown(() => tester.binding.setSurfaceSize(null));