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
353 lines
15 KiB
Dart
353 lines
15 KiB
Dart
/// **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);
|
||
});
|
||
});
|
||
}
|