etm-powersync-app/test/eco_levels_test.dart
Patrick Schurig 0df80b5b9b fix(écran): un instant d'un autre jour porte son jour, et le motif ne rapetisse plus la grandeur
Deux défauts vus sur appareil après installation, tous deux d'affichage.

1. « mesuré depuis 16:37 » à 06:15, pour un `measuredSince` de la VEILLE. Le
   lecteur comprend 16:37 aujourd'hui — une heure qui n'est pas encore arrivée —
   et « 2,5 kWh achetés » juste à côté devient incompréhensible avec elle.
   Même défaut que l'heure UTC, d'un cran plus haut : là on perdait le fuseau,
   ici le jour. `heureLisible` rend « HH:MM » dans la journée du lecteur,
   « hier HH:MM » la veille, « le JJ/MM à HH:MM » au-delà — et la comparaison
   porte sur le jour civil, pas sur 24 h : à 00:05, un instant de 23:50 est hier
   même s'il a quinze minutes.

2. « Autoconsommation · sans production » rendu à la moitié de la taille
   d'« Autonomie » à côté. Cause : un FittedBox reçoit des contraintes de
   largeur infinies, donc son enfant tient sur une ligne aussi large qu'il veut
   et c'est tout le bloc qui rétrécit — `maxLines: 2` n'y sert à rien.
   Rallonger le texte d'un suffixe rapetissait donc le NOM de la grandeur, qui
   est ce qu'on lit en premier. Le motif passe sur sa propre ligne.

Les tests qui figeaient « HH:MM » dérivent maintenant de `heureLisible`, la
fonction même de l'écran — un attendu recomposé à la main aurait gelé la forme
d'avant, exactement comme les « 03:42 » en dur de vendredi.

Captures : docs/captures/2026-09-08_taux-sans-production.png
           docs/captures/2026-09-08_instant-hier.png

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-09-08 06:31:52 +02:00

353 lines
15 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/// **La carte à deux niveaux** — la décomposition d'UNE commande, éprouvée sur ce qu'elle
/// affiche.
///
/// Les charges utiles viennent de `.75`, pas d'une lecture de contrat : le cas
/// « éco servi / confort en attente » est celui du 2026-09-01 à 05:47, où le surplus est
/// passé à −922 W pendant que le plancher achetait 767 W au réseau. Le cas mesuré est celui
/// du 2026-09-04, huit cycles de décroissance.
///
/// Ce qu'ils figent, et qui a coûté une semaine à établir :
///
/// - le motif RACINE ne suffit pas — il valait `SURPLUS_INSUFFICIENT` pendant que le niveau
/// éco achetait. Une vue fausse, pas incomplète ;
/// - `deliveredWh` absent sous `unmeasurable` n'est pas zéro, et `remainingWh == targetWh`
/// s'y lit « rien de mesurable », jamais « rien livré » ;
/// - `ECO_FLOOR_MET` sous `unmeasurable` est une incohérence de la box (R6) : on la DÉNONCE
/// au lieu de la rendre ;
/// - l'acheté s'affiche à côté du livré, jamais à sa place.
library;
import 'package:etm_powersync_app/l10n/app_localizations.dart';
import 'package:etm_powersync_app/models/load_telemetry.dart';
import 'package:etm_powersync_app/services/telemetry_text.dart';
import 'package:etm_powersync_app/providers/load_telemetry_provider.dart';
import 'package:etm_powersync_app/widgets/load_cards.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
/// Relevé `.75` du 2026-09-01 05:47 — surplus négatif, plancher acheté au réseau.
Map<String, dynamic> _payload({
Map<String, dynamic>? progressEco,
Map<String, dynamic>? decisionEco,
}) =>
{
'degradedMode': false,
'timestamp': '2026-09-01T05:47:00Z',
'budget': {
'surplusW': -922.0, 'evReservedW': 0.0, 'recreditedW': 6500.0,
'allocatedW': 0.0, 'remainingW': -922.0,
},
'draw': {'committedW': 766.81},
'loads': [
{
'loadId': 'x',
'allocatedW': 766.81,
'available': true,
'measurement': {'source': 'none'},
// Le motif RACINE : ce qu'un client d'avant `levels[]` lirait, et il est faux.
'decision': {
'code': 'SURPLUS_INSUFFICIENT',
'level': 'comfort',
'params': {'budgetW': -922},
},
'levels': [
{
'level': 'eco',
'funding': 'grid',
'targetW': 766.81,
'counts': {'draw.committedW': 766.81},
'progress': progressEco ??
{'regime': 'unmeasurable', 'targetWh': 4000.0, 'purchasedWh': 43.12},
'decision': decisionEco ??
{
'code': 'ECO_FLOOR_GRID',
'params': {
'gridW': 767, 'remainingWh': 4000, 'targetWh': 4000,
'remainingS': 18779, 'regime': 'unmeasurable', 'atMaximum': false,
},
},
},
{
'level': 'comfort',
'funding': 'surplus',
'targetW': 0.0,
'counts': {'budget.allocatedW': 0.0},
'decision': {
'code': 'SURPLUS_INSUFFICIENT',
'params': {'budgetW': -922},
},
},
],
},
],
};
Widget _carte(Map<String, dynamic> payload) {
final tel = LoadTelemetryProvider()..adoptForTest(payload);
return MaterialApp(
locale: const Locale('fr'),
localizationsDelegates: L10n.localizationsDelegates,
supportedLocales: L10n.supportedLocales,
home: Scaffold(
body: LoadLiveBlock(live: tel.entryFor('x')!, stale: false, telemetry: tel),
),
);
}
void main() {
group('une commande, deux lignes', () {
testWidgets('les deux niveaux s\'affichent, et l\'éco dit l\'ACHAT',
(tester) async {
await tester.pumpWidget(_carte(_payload()));
expect(find.text('ÉCO'), findsOneWidget);
expect(find.text('CONFORT'), findsOneWidget);
expect(find.textContaining('767 W achetés au réseau'), findsOneWidget);
// Le confort ne dit pas « rien » : il dit qu'il n'y avait rien à prendre.
expect(find.textContaining('Aucun surplus'), findsOneWidget);
});
testWidgets('le motif RACINE ne s\'affiche pas seul — il serait faux ici',
(tester) async {
await tester.pumpWidget(_carte(_payload()));
// `SURPLUS_INSUFFICIENT` est bien rendu, mais SOUS l'étiquette CONFORT, à côté d'un
// niveau éco qui achète. Ce qui est interdit, c'est qu'il soit la seule phrase.
expect(find.textContaining('achetés au réseau'), findsOneWidget);
});
testWidgets('sous `unmeasurable` : ni fraction, ni promesse d\'escalade',
(tester) async {
await tester.pumpWidget(_carte(_payload()));
// « reste 4,0 kWh » et NON « 0 sur 4,0 » : rien n'est soustrait, donc la fraction
// n'aurait aucun sens — et « 0 » se lirait « rien livré ».
expect(find.textContaining('reste 4,0 kWh à livrer'), findsOneWidget);
expect(find.textContaining('non vérifiable'), findsOneWidget);
// Le taux est CONSTANT depuis +etm55 : ne jamais annoncer une intensification.
expect(find.textContaining('taux constant'), findsOneWidget);
expect(find.textContaining('accélère'), findsNothing);
expect(find.textContaining('non tenue'), findsNothing);
});
testWidgets('l\'ACHETÉ s\'affiche à côté du livré, jamais à sa place',
(tester) async {
await tester.pumpWidget(_carte(_payload()));
expect(find.textContaining('43 Wh achetés pour cette obligation'), findsOneWidget);
});
});
group('régime mesuré — relevé du 2026-09-04', () {
// `measuredSince` est publié en UTC ; l'écran le rend dans le fuseau du LECTEUR.
// L'attendu se dérive donc, il ne se recopie pas : le fixer à « 03:42 » gelait
// l'heure UTC et validait le défaut trouvé sur appareil le 2026-09-06.
final _instant = DateTime.utc(2026, 9, 4, 3, 42);
// ⚠️ L'attendu se dérive de la MÊME fonction que l'écran — `heureLisible` — et pas
// d'un « HH:MM » recomposé à la main : depuis le 2026-09-08 un instant d'un autre jour
// porte son jour, et un attendu nu gèlerait la forme d'avant.
final _heure = heureLisible(_instant.toLocal());
Map<String, dynamic> mesure() => _payload(
progressEco: {
'regime': 'measured',
'targetWh': 3000.0,
'deliveredWh': 750.0,
'purchasedWh': 236.5,
'measuredSince': '2026-09-04T03:42:00Z',
},
decisionEco: {
'code': 'ECO_FLOOR_GRID',
'params': {
'gridW': 443, 'remainingWh': 2250, 'targetWh': 3000,
'remainingS': 18299, 'regime': 'measured', 'atMaximum': false,
},
},
);
testWidgets('la fraction s\'affiche, et la date de mesure avec', (tester) async {
await tester.pumpWidget(_carte(mesure()));
expect(find.textContaining('750 Wh sur 3,0 kWh livrés'), findsOneWidget);
// Après une bascule en cours de période, `deliveredWh` ne compte que depuis cette
// date pendant que la cible reste entière : sans elle, la paire mentirait.
expect(find.textContaining('mesuré depuis $_heure'), findsOneWidget);
expect(find.textContaining('non vérifiable'), findsNothing);
});
testWidgets('l\'intermittence se dit, et la date ne bouge pas', (tester) async {
final p = mesure();
(p['loads'] as List)[0]['levels'][0]['progress']['measuredGaps'] = [
{'x': 1}
];
await tester.pumpWidget(_carte(p));
expect(find.textContaining('mesuré par intermittence depuis $_heure'),
findsOneWidget);
});
});
group('R6 — servie oui, tenue jamais', () {
testWidgets('`ECO_FLOOR_MET` sous `unmeasurable` est DÉNONCÉ, pas rendu',
(tester) async {
await tester.pumpWidget(_carte(_payload(
decisionEco: {
'code': 'ECO_FLOOR_MET',
'params': {'deliveredWh': 4000, 'targetWh': 4000},
},
)));
expect(find.textContaining('Incohérence de la box'), findsOneWidget);
expect(find.textContaining('atteint —'), findsNothing);
});
testWidgets('le plancher DÉPASSÉ ne s\'écrit pas comme une fraction',
(tester) async {
// Vu sur appareil le 2026-09-06 : « Plancher atteint — 29,9 kWh sur 3,0 kWh
// livrés ». Le compteur continue de courir après que l'obligation est tenue, donc
// le numérateur dépasse le dénominateur et la fraction cesse d'en être une.
await tester.pumpWidget(_carte(_payload(
progressEco: {
'regime': 'measured', 'targetWh': 3000.0, 'deliveredWh': 29900.0,
'purchasedWh': 127.0, 'measuredSince': '2026-09-05T08:00:00Z',
},
decisionEco: {
'code': 'ECO_FLOOR_MET',
'params': {'deliveredWh': 29900, 'targetWh': 3000},
},
)));
expect(find.textContaining('3,0 kWh dus, 29,9 kWh livrés'), findsOneWidget);
expect(find.textContaining('sur 3,0 kWh'), findsNothing);
});
testWidgets('et la fraction n\'est PAS répétée sous le motif', (tester) async {
// Le motif porte déjà livré et cible ; la ligne d'avancement ne garde que ce qu'il
// ne dit pas — l'origine de la mesure.
await tester.pumpWidget(_carte(_payload(
progressEco: {
'regime': 'measured', 'targetWh': 3000.0, 'deliveredWh': 29900.0,
'purchasedWh': 127.0, 'measuredSince': '2026-09-05T08:00:00Z',
},
decisionEco: {
'code': 'ECO_FLOOR_MET',
'params': {'deliveredWh': 29900, 'targetWh': 3000},
},
)));
expect(find.textContaining('29,9 kWh livrés'), findsOneWidget,
reason: 'une seule fois, dans le motif');
expect(find.textContaining('mesuré depuis'), findsOneWidget);
});
testWidgets('un instant d\'un AUTRE JOUR porte son jour', (tester) async {
// Vu sur appareil le 2026-09-08 à 06:15 : « mesuré depuis 16:37 » pour un
// `measuredSince` du 7. Le lecteur comprend 16:37 aujourd'hui — une heure qui n'est
// pas encore arrivée — et « 2,4 kWh achetés » à côté devient incompréhensible.
//
// Même défaut que l'heure UTC, d'un cran plus haut : là on perdait le fuseau, ici le
// jour. « HH:MM » n'est une abréviation légitime que dans la journée du lecteur.
final hier = DateTime.now().subtract(const Duration(days: 1));
final u = DateTime(hier.year, hier.month, hier.day, 16, 37).toUtc();
await tester.pumpWidget(_carte(_payload(
progressEco: {
'regime': 'measured', 'targetWh': 2000.0, 'deliveredWh': 0.0,
'purchasedWh': 2400.0,
'measuredSince': u.toIso8601String(),
},
decisionEco: {
'code': 'ECO_FLOOR_GRID',
'params': {
'gridW': 350, 'remainingWh': 2000, 'targetWh': 2000,
'remainingS': 3600, 'regime': 'measured', 'atMaximum': false,
},
},
)));
expect(find.textContaining('mesuré depuis hier 16:37'), findsOneWidget);
// Le contrôle qui MORD : l'heure nue ne doit plus passer seule.
expect(find.textContaining('mesuré depuis 16:37'), findsNothing);
});
testWidgets('« mesuré depuis » se rend dans le fuseau du LECTEUR',
(tester) async {
// Trouvé sur appareil le 2026-09-06, pendant que `.75` était volontairement en
// `Europe/London` : la box publiait `2026-09-06T14:38:00Z` et l'écran affichait
// « mesuré depuis 14:38 » — l'heure UTC, qui n'était celle de personne (15:38 à la
// box, 16:38 au téléphone). `DateTime.tryParse` d'une chaîne en `Z` rend un
// `DateTime` **UTC**, et `.hour` sur un UTC donne l'heure UTC.
//
// ⚠️ La bascule a donné le MOTIF de regarder, pas la visibilité : l'écart est entre
// UTC et l'appareil, donc visible sur tout téléphone hors UTC+0 quel que soit le
// fuseau de la box (vérifié le 2026-09-07, les deux en Europe/Paris).
final instant = DateTime.utc(2026, 9, 6, 14, 38);
final local = instant.toLocal();
final attendu = heureLisible(local);
await tester.pumpWidget(_carte(_payload(
progressEco: {
'regime': 'measured', 'targetWh': 2000.0, 'deliveredWh': 0.0,
'purchasedWh': 325.0, 'measuredSince': '2026-09-06T14:38:00Z',
},
decisionEco: {
'code': 'ECO_FLOOR_GRID',
'params': {
'gridW': 116, 'remainingWh': 2000, 'targetWh': 2000,
'remainingS': 3600, 'regime': 'measured', 'atMaximum': false,
},
},
)));
expect(find.textContaining('mesuré depuis $attendu'), findsOneWidget);
// Le contrôle qui MORD : l'heure UTC ne doit plus apparaître. Il ne mord que hors
// UTC — dans un environnement à offset nul, `local == instant` et l'assertion
// ci-dessus passerait avec l'ancien code comme avec le nouveau. Un vert obtenu là
// ne prouve rien, et c'est écrit ici pour que personne ne le lise comme une preuve.
if (local.hour != instant.hour) {
expect(find.textContaining('mesuré depuis 14:38'), findsNothing,
reason: 'l\'heure UTC ne doit jamais atteindre l\'écran');
}
});
testWidgets('sous `measured`, il se rend normalement', (tester) async {
await tester.pumpWidget(_carte(_payload(
progressEco: {
'regime': 'measured', 'targetWh': 4000.0, 'deliveredWh': 4000.0,
'purchasedWh': 1200.0,
},
decisionEco: {
'code': 'ECO_FLOOR_MET',
'params': {'deliveredWh': 4000, 'targetWh': 4000},
},
)));
expect(find.textContaining('Plancher atteint'), findsOneWidget);
expect(find.textContaining('Incohérence'), findsNothing);
});
});
group('ECO_FLOOR_MISSED — la cause décide de la phrase', () {
testWidgets('sous `unmeasurable` : ni « non tenue », ni chiffre du manque',
(tester) async {
await tester.pumpWidget(_carte(_payload(
decisionEco: {
'code': 'ECO_FLOOR_MISSED',
'params': {'cause': 'unmeasurable'},
},
)));
// Deux lignes portent « non vérifiable » — le motif et l'avancement — et c'est
// cohérent : l'obligation est abandonnée POUR cette raison. On vise donc la clause
// qui n'appartient qu'au motif.
expect(find.textContaining('ni tenue, ni chiffrable'), findsOneWidget);
expect(find.textContaining('il manquait'), findsNothing);
});
testWidgets('le plafond de soutirage chiffre son manque, lui', (tester) async {
await tester.pumpWidget(_carte(_payload(
progressEco: {
'regime': 'measured', 'targetWh': 4000.0, 'deliveredWh': 1500.0,
'purchasedWh': 900.0,
},
decisionEco: {
'code': 'ECO_FLOOR_MISSED',
'params': {'cause': 'drawCap', 'missingWh': 2500},
},
)));
expect(find.textContaining('plafond de soutirage'), findsOneWidget);
expect(find.textContaining('il manquait 2,5 kWh'), findsOneWidget);
});
});
}