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
323 lines
14 KiB
Dart
323 lines
14 KiB
Dart
/// **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);
|
||
});
|
||
});
|
||
}
|