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
236 lines
9.3 KiB
Dart
236 lines
9.3 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', () {
|
||
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);
|
||
});
|
||
});
|
||
}
|