fix(fuseau): un INSTANT se rend chez le lecteur — « mesuré depuis » affichait l'heure UTC

Trouvé sur appareil pendant la bascule volontaire de .75 en Europe/London, en
capturant la ligne d'horloge de l'échéance : la box publie
`measuredSince: "2026-09-06T14:38:00Z"` et l'écran affichait « mesuré depuis
14:38 » — ni l'heure de la box (15:38 BST), ni celle du téléphone (16:38 CEST).
L'heure de personne.

`DateTime.tryParse` d'une chaîne en `Z` rend un DateTime `isUtc: true` ; lire
`.hour` sans `toLocal()` donne l'heure UTC. Le défaut est identiquement
invisible tant que box et appareil partagent l'offset : il a fallu DEUX fuseaux
pour qu'il se voie, et c'est la couture ouverte pour la capture qui l'a montré.

Deux natures d'heure, désormais séparées dans CLAUDE.md :
- un RÉGLAGE (`dailyDeadline`) est lu par la box → on nomme son horloge ;
- un INSTANT (`measuredSince`) est absolu → il se rend chez le lecteur, sans
  mention.

Les deux tests qui gardaient « 03:42 » en dur gelaient l'heure UTC et validaient
le défaut : leur attendu se dérive maintenant de `.toLocal()`. Le contrôle
négatif ne mord que hors UTC, et le test le dit au lieu de laisser lire un vert
comme une preuve.

Capture de la ligne d'horloge : docs/captures/2026-09-06_fuseau-box-london.png

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-06 19:57:38 +02:00
parent 9424598d58
commit b843d8936a
4 changed files with 82 additions and 3 deletions

View File

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

View File

@ -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')}';

View File

@ -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<String, dynamic> 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: {