/// **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/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 _payload({ Map? progressEco, Map? 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 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); final _local = _instant.toLocal(); final _heure = '${_local.hour.toString().padLeft(2, '0')}:' '${_local.minute.toString().padLeft(2, '0')}'; Map 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('« 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 = '${local.hour.toString().padLeft(2, '0')}:' '${local.minute.toString().padLeft(2, '0')}'; 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); }); }); }