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:
parent
9424598d58
commit
b843d8936a
20
CLAUDE.md
20
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
|
||||
|
||||
BIN
docs/captures/2026-09-06_fuseau-box-london.png
Normal file
BIN
docs/captures/2026-09-06_fuseau-box-london.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 254 KiB |
@ -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')}';
|
||||
|
||||
@ -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: {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user