Deuxième passage sur appareil, écran des charges. Trois défauts, dont deux de notre fait et un que les fixtures ne pouvaient pas produire. 1. LA FRACTION ÉTAIT ÉCRITE DEUX FOIS — `ECO_FLOOR_MET` porte déjà livré et cible dans son motif, et la ligne d'avancement les réécrivait dessous : « Plancher atteint — 29,9 kWh sur 3,0 kWh livrés » puis « 29,9 kWh sur 3,0 kWh livrés · mesuré depuis 10:00 ». C'est le défaut de la ligne des ratios de la veille, refait à l'identique : déclencher sur ce qu'on SAIT plutôt que sur ce qui se VOIT. La ligne ne garde plus que ce que le motif ne dit pas. 2. « X SUR Y » N'EST PAS UNE FRACTION une fois le plancher dépassé — le compteur continue de courir après que l'obligation est tenue, et le numérateur finit à dix fois le dénominateur. Aucune fixture ne le montrait : toutes avaient un livré inférieur à la cible. Reformulé « 3,0 kWh dus, 30,2 kWh livrés ». 3. LE PARENT NE DOMINAIT PAS — à taille et graisse égales, l'allocation et les deux niveaux se lisaient comme trois lignes sœurs, et les pastilles colorées tiraient l'œil davantage que la commande qu'elles expliquent : le contresens exact que la forme doit empêcher (LM-1003). L'allocation passe en 13,5 gras, les pastilles perdent leur fond, et un filet vertical rattache chaque niveau à la commande. Vérifié à l'écran après correction, et le repli l'était déjà : V2C et Simulated wallbox tiennent sur une seule ligne, sans étiquette orpheline ni vide résiduel. Et le signe du solaire est CONFIRMÉ, ce qui n'avait pas pu l'être hier faute de production : la box publie -4 376 W, l'écran affiche « 4.4 kW ». Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
273 lines
11 KiB
Dart
273 lines
11 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('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('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);
|
||
});
|
||
});
|
||
}
|