etm-powersync-app/test/eco_levels_test.dart
Patrick Schurig 58bf57e67f feat(§10): la carte de charge porte ses deux niveaux — une commande, deux lignes
Premier lot d'écran, sur des champs tous posés (+etm55) et tous vus à la sonde.
L'allocation reste le parent ; les niveaux en sont la décomposition. Une charge
reçoit UNE commande par cycle quelles que soient les passes (LM-1003) : deux
lignes décrivent une seule décision.

- `EcoProgress` — regime, targetWh, deliveredWh (absent sous `unmeasurable`,
  jamais 0), purchasedWh (publié dans TOUS les régimes : c'est la commande du
  moteur, donc un fait connu sans mesure), measuredSince, measuredGaps ;
- `LoadLevel` gagne `decision` et `progress` : chaque passe porte son motif.
  Lire la racine seule afficherait « rien fait » sur une charge qui achète —
  relevé du 2026-09-01 05:47, `SURPLUS_INSUFFICIENT` en racine et 767 W achetés
  au niveau éco ;
- `ecoDecisionText` / `ecoProgressText` : les phrases des trois motifs éco et de
  leurs quatre causes. Aucun nom de paramètre n'est deviné — `cause`,
  `missingWh`, les quatre valeurs et `measuredGaps` sont vérifiés dans le binaire
  posé. Seule la FORME de `measuredGaps` reste non observée, donc on n'en lit que
  la présence ;
- garde R6 à l'écran : `ECO_FLOOR_MET` sous `unmeasurable` est DÉNONCÉ comme une
  incohérence de la box, pas rendu. Servie oui, tenue jamais.

Deux interdits écrits dans le code, pas seulement dans la maquette : sous
`unmeasurable` on n'annonce pas que l'achat va s'intensifier (le taux est
constant depuis +etm55), et `atMaximum: false` n'y veut pas dire « on n'est pas
au plafond » mais « il n'y a aucun étalement, donc rien qui puisse cesser ». Un
booléen à false se lit spontanément comme rassurant.

Les charges utiles des tests viennent de `.75` — le cas éco-servi/confort-en-
attente du 2026-09-01, le cas mesuré du 2026-09-04 — pas d'une lecture de
contrat.

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

236 lines
9.3 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', () {
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 03:42'), 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 03:42'), 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('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);
});
});
}