etm-powersync-app/test/eco_levels_test.dart
Patrick Schurig a430fb00c8 fix(instants): un seul lecteur d'horodatage de box — et ma cause d'hier était FAUSSE
Le contrat porte désormais la règle : tout horodatage publié est en UTC avec son
marqueur, ou en epoch. Trois champs, deux formats, rien ne l'exigeait.

`instantDeLaBox` (lib/models/box_instant.dart) devient l'unique lecteur pour ce
que la box publie — epoch ou ISO, marqué ou nu — et requalifie la forme nue en
UTC avant de rendre l'heure du lecteur. Politique valable pour la BOX seule :
`Installation.lastSeen` est écrite en local sans marqueur et relue pareil, donc
la lire en UTC la décalerait. Deux héritages opposés sous une même forme, d'où
l'absence délibérée d'un « parser une date » unique.

Le `.toLocal()` posé hier dans telemetry_text est retiré : le modèle est seul
propriétaire de la conversion. Le rejouer serait sans effet mais laisserait
croire qu'elle peut manquer ailleurs.

CORRECTION. J'avais écrit que le défaut avait survécu « parce que box et
téléphone partageaient l'heure ». C'est faux, et la fausseté est utile : 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. Mesuré ce matin avec les deux en Europe/Paris — la box
publie 04:33:28Z, l'APK d'hier affiche « 04:33 », il fallait lire 06:33. La
bascule en Europe/London n'a pas donné la visibilité, elle a donné le MOTIF de
regarder. La version fausse fait conclure « les fuseaux coïncident, donc c'est
réglé » et laisse le défaut en place.

`GetVariableLoadState` n'existe pas sur .75 (absente d'Introspect) et l'app ne
lit aucun `since` : rien à corriger de ce côté. Le champ existe en revanche dans
GetLoadTelemetry — `loads[].command {divergent, expectedW, observedW, since}` —
et il porte bien le Z, vérifié à la sonde. Aucun écran ne le consomme encore.

Contrôle négatif de la ligne d'horloge : même binaire qu'hier, box repassée en
Europe/Paris, la ligne a disparu sous le champ d'échéance.
docs/captures/2026-09-07_fuseau-box-paris-ligne-absente.png

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-09-07 07:34:24 +02:00

323 lines
14 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/// **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<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);
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',
'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);
});
});
}