diff --git a/CLAUDE.md b/CLAUDE.md index e8fe7d6..fc28309 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -442,6 +442,26 @@ Vaut aussi pour **toute fenêtre tarifaire** à venir. Un lot moteur est ouvert vient le fuseau et ce qu'il fait quand il diverge ; d'ici là, l'app **dit** l'horloge au lieu de la choisir. +### Deux natures d'heure, deux traitements — et un piège entre les deux + +Un **réglage** (`dailyDeadline`) est une chaîne sans fuseau que la BOX interprète : on l'affiche +telle quelle et on **nomme l'horloge** qui la lit. Un **instant** (`measuredSince`, +`periodStartedAt`, `timestamp`) est un point du temps absolu : il se rend dans le fuseau du +**lecteur**, et n'a besoin d'aucune mention. + +> 🔴 Le piège est entre les deux. Les instants arrivent en **UTC** — `measuredSince` vaut +> `"2026-09-06T14:38:00Z"`. `DateTime.tryParse` d'une chaîne en `Z` rend un `DateTime` dont +> `isUtc` est **vrai**, et `.hour` donne alors l'heure **UTC**. Sans `.toLocal()`, l'écran +> affiche une heure qui n'est celle de **personne** : vu sur appareil le 2026-09-06 avec `.75` +> en `Europe/London`, « mesuré depuis 14:38 » quand il était 15:38 à la box et 16:38 au +> téléphone. +> +> Ce défaut est **invisible à offset nul**. Il a survécu à toute la campagne parce que box et +> téléphone partageaient l'heure ; il a fallu deux fuseaux distincts pour qu'un instant mal +> rendu se voie. Corollaire pour les tests : un attendu d'heure **se dérive** de `.toLocal()`, +> il ne se recopie jamais depuis la charge utile — un « 03:42 » écrit en dur gèle l'heure UTC +> et valide le défaut. Et sous `TZ=UTC`, ce contrôle-là ne mord pas : le dire dans le test. + --- ## Un mode d'érosion à part : l'étiquette d'honnêteté qui blanchit une fabrication diff --git a/docs/captures/2026-09-06_fuseau-box-london.png b/docs/captures/2026-09-06_fuseau-box-london.png new file mode 100644 index 0000000..6069919 Binary files /dev/null and b/docs/captures/2026-09-06_fuseau-box-london.png differ diff --git a/lib/services/telemetry_text.dart b/lib/services/telemetry_text.dart index 9341531..b095adc 100644 --- a/lib/services/telemetry_text.dart +++ b/lib/services/telemetry_text.dart @@ -365,7 +365,17 @@ String? ecoProgressText(L10n t, LoadLevel niveau) { final base = dejaDit ? null : t.ecoAvancementMesure(energieCourte(livre), energieCourte(p.targetWh)); - final depuis = p.measuredSince; + // ⚠️ `measuredSince` arrive en **UTC** (`2026-09-06T14:38:00Z` sur `.75`), et + // `DateTime.tryParse` garde `isUtc: true` : lire `.hour` sans `toLocal()` affiche l'heure + // UTC. Le 2026-09-06, la box était en `Europe/London` et le téléphone en CEST — l'écran + // disait « mesuré depuis 14:38 » quand il était 15:38 à la box et 16:38 au téléphone. + // Une heure qui n'est celle de PERSONNE, et que les deux fuseaux à égalité d'offset + // rendaient invisible jusqu'ici. + // + // Un INSTANT se rend dans le fuseau du lecteur — contrairement à l'échéance + // d'obligation, qui est un réglage que la box interprète et porte donc sa mention de + // fuseau (cf. `obligationEcheanceHorloge`). + final depuis = p.measuredSince?.toLocal(); if (depuis == null) return base; final h = '${depuis.hour.toString().padLeft(2, '0')}:' '${depuis.minute.toString().padLeft(2, '0')}'; diff --git a/test/eco_levels_test.dart b/test/eco_levels_test.dart index 75eca05..66be2a6 100644 --- a/test/eco_levels_test.dart +++ b/test/eco_levels_test.dart @@ -136,6 +136,14 @@ void main() { }); 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', @@ -158,7 +166,7 @@ void main() { 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 03:42'), findsOneWidget); + expect(find.textContaining('mesuré depuis $_heure'), findsOneWidget); expect(find.textContaining('non vérifiable'), findsNothing); }); @@ -168,7 +176,8 @@ void main() { {'x': 1} ]; await tester.pumpWidget(_carte(p)); - expect(find.textContaining('mesuré par intermittence depuis 03:42'), findsOneWidget); + expect(find.textContaining('mesuré par intermittence depuis $_heure'), + findsOneWidget); }); }); @@ -222,6 +231,46 @@ void main() { 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. + // + // Le défaut était invisible tant que box et téléphone partageaient l'offset : il + // fallait DEUX fuseaux distincts pour qu'un instant mal rendu se voie. + 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: {