Décision de Patrick : trois modes suffisent — Manuel, PV, Min+PV. Le boost n'est pas un quatrième mode, c'est la dérogation (+etm61). LE FAIT. Le bouton écrivait `ChargingModeNormal`, qui sort la borne de l'arbitrage et lui restitue ses valeurs manuelles stockées — manualChargingEnabled, manualMaxChargingCurrent, manualDesiredPhaseCount. Sur .75 elles valaient false / 6 A / 3 phases : mesuré le 2026-09-08 à 11:09:51, l'appui a ARRÊTÉ la charge et armé un verrou de 300 s. Pas une régression — c'est ce que Normal a toujours fait. C'est le nom qui mentait. Renommé jusque dans le code (`ChargingMode.boost` -> `ChargingMode.manuel`), pas seulement sur l'étiquette : un identifiant faux reste un piège pour le prochain lecteur, et c'est précisément ce qui a duré des mois ici. L'ordre met Manuel en tête, parce que ChargingModeNormal est aussi le mode d'office d'une borne détectée. ET LE RENOMMAGE NE SUFFISAIT PAS. « Manuel » dit QUI commande, pas ce que la borne va faire : tant que l'app n'offre pas de régler ces valeurs stockées, le mode veut dire « ce qui traînait la dernière fois ». Passer en manuel demande donc confirmation, et la confirmation dit la conséquence — la charge peut s'arrêter — AVANT, pas après. ⚠️ La confirmation ne se déclenche pas sur un ré-appui du mode déjà en place : une confirmation qu'on apprend à cliquer sans lire ne protège plus de rien. Un test le fige. Vérifié sur .75 (1.15.2+etm61) : SetLoadDerogation et ClearLoadDerogation sont dans Introspect, avec derogationEnded / durationS / restoredMode. Le contrat de la dérogation est écrit dans CLAUDE.md — durée obligatoire bornée [60, 21600], état lu dans decision.code == "DEROGATION_ACTIVE" et jamais dans le mode, fin au niveau de la trame avec six causes. Aucun bouton de boost n'est écrit : c'est le lot suivant. Et une sonde pour le moteur, faite au passage : la V2C est bien restée en ChargingModeNormal et elle est ABSENTE de loads[] — hors arbitrage. Leur hypothèse est confirmée sans redémarrage. 339 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
199 lines
6.9 KiB
Dart
199 lines
6.9 KiB
Dart
/// Les trois boutons de mode — **le seul lieu d'écriture de `SetChargingInfo`**.
|
|
///
|
|
/// Ils ont vécu sur la tuile des favoris, sur des pastilles de 7 à 10 px qui avalaient les
|
|
/// refus. Ils sont depuis le 2026-08-28 sur l'écran « Configurer » de la borne, en un seul
|
|
/// exemplaire : deux endroits qui écrivent la même chose sont deux comportements qui
|
|
/// divergent.
|
|
library;
|
|
|
|
import 'package:etm_powersync_app/models/energy_data.dart' show ChargingMode;
|
|
import 'package:etm_powersync_app/models/ev_charger_live.dart';
|
|
import 'package:etm_powersync_app/widgets/ev_mode_buttons.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
|
|
EvChargerLive _v2c({String? apiMode = 'ChargingModeNormal'}) => EvChargerLive(
|
|
thingId: '4d1adafd-cafc-4813-93b1-ca8933ce71ab',
|
|
name: 'V2C Trydan',
|
|
pluggedIn: false,
|
|
charging: false,
|
|
currentPowerW: 0,
|
|
apiMode: apiMode,
|
|
);
|
|
|
|
Widget _harness({
|
|
required EvChargerLive live,
|
|
Future<String?> Function(ChargingMode)? onSetMode,
|
|
}) =>
|
|
MaterialApp(
|
|
home: Scaffold(
|
|
body: EvModeButtons(
|
|
live: live,
|
|
onSetMode: onSetMode ?? (_) async => null,
|
|
),
|
|
),
|
|
);
|
|
|
|
void main() {
|
|
testWidgets('un refus de la box est affiché, avec son code', (tester) async {
|
|
await tester.pumpWidget(_harness(
|
|
live: _v2c(),
|
|
onSetMode: (_) async => 'EnergyErrorInvalidParameter',
|
|
));
|
|
|
|
await tester.tap(find.text('PV'));
|
|
await tester.pump(); // écriture en cours
|
|
await tester.pump(); // réponse
|
|
await tester.pump(); // SnackBar
|
|
|
|
expect(find.textContaining('EnergyErrorInvalidParameter'), findsOneWidget);
|
|
expect(find.textContaining('a refusé'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('un succès n\'affiche rien — c\'est la box qui dira le mode',
|
|
(tester) async {
|
|
var appels = 0;
|
|
await tester.pumpWidget(_harness(
|
|
live: _v2c(),
|
|
onSetMode: (m) async {
|
|
appels++;
|
|
return null;
|
|
},
|
|
));
|
|
|
|
await tester.tap(find.text('PV'));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(appels, 1);
|
|
expect(find.textContaining('a refusé'), findsNothing);
|
|
});
|
|
|
|
testWidgets('la sélection suit la BOX, pas la dernière commande', (tester) async {
|
|
// `_v2c()` est en ChargingModeNormal : c'est Manuel qui doit être allumé, et lui seul,
|
|
// quoi qu'on ait tapé juste avant.
|
|
await tester.pumpWidget(_harness(live: _v2c()));
|
|
await tester.tap(find.text('PV'));
|
|
await tester.pumpAndSettle();
|
|
|
|
final manuel = tester.widget<OutlinedButton>(
|
|
find.widgetWithText(OutlinedButton, 'Manuel'));
|
|
final pv =
|
|
tester.widget<OutlinedButton>(find.widgetWithText(OutlinedButton, 'PV'));
|
|
expect(manuel.style?.backgroundColor?.resolve({}),
|
|
isNot(Colors.transparent),
|
|
reason: 'Manuel est le mode que la box porte');
|
|
expect(pv.style?.backgroundColor?.resolve({}), Colors.transparent,
|
|
reason: 'PV vient d\'être demandé, mais la box ne l\'a pas encore dit');
|
|
});
|
|
|
|
testWidgets('un mode non lu n\'allume RIEN', (tester) async {
|
|
// Trois boutons éteints valent mieux qu'un allumé au hasard.
|
|
await tester.pumpWidget(_harness(live: _v2c(apiMode: null)));
|
|
for (final label in ['Manuel', 'PV', 'Min+PV']) {
|
|
final b = tester
|
|
.widget<OutlinedButton>(find.widgetWithText(OutlinedButton, label));
|
|
expect(b.style?.backgroundColor?.resolve({}), Colors.transparent);
|
|
}
|
|
});
|
|
|
|
testWidgets('les cibles tactiles font au moins 44 px', (tester) async {
|
|
await tester.pumpWidget(_harness(live: _v2c()));
|
|
for (final label in ['Manuel', 'PV', 'Min+PV']) {
|
|
final taille = tester.getSize(find.ancestor(
|
|
of: find.text(label),
|
|
matching: find.byType(OutlinedButton),
|
|
));
|
|
expect(taille.height, greaterThanOrEqualTo(44),
|
|
reason: '$label : cible tactile trop petite');
|
|
}
|
|
});
|
|
|
|
group('« Manuel » se confirme — il peut ARRÊTER la charge', () {
|
|
// Le bouton s'appelait « Boost ». `ChargingModeNormal` sort la borne de l'arbitrage et
|
|
// lui restitue ses valeurs manuelles stockées : sur `.75` elles valaient
|
|
// `manualChargingEnabled: false`, et l'appui a stoppé la charge le 2026-09-08 à
|
|
// 11:09:51, avec un verrou de 300 s derrière.
|
|
//
|
|
// Renommer ne suffisait pas : « Manuel » dit QUI commande, pas ce que la borne va
|
|
// faire. Tant que l'app n'offre pas de régler ces valeurs, le mode veut dire « ce qui
|
|
// traînait la dernière fois ».
|
|
|
|
testWidgets('l\'appui n\'écrit RIEN tant qu\'on n\'a pas confirmé', (tester) async {
|
|
var appels = 0;
|
|
await tester.pumpWidget(_harness(
|
|
live: _v2c(apiMode: 'ChargingModeEco'),
|
|
onSetMode: (_) async {
|
|
appels++;
|
|
return null;
|
|
},
|
|
));
|
|
|
|
await tester.tap(find.text('Manuel'));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.textContaining('Rendre la main à la borne'), findsOneWidget);
|
|
expect(appels, 0, reason: 'rien ne part avant que l\'utilisateur ait dit oui');
|
|
expect(find.textContaining('la charge peut s\'arrêter'), findsOneWidget,
|
|
reason: 'la conséquence se dit AVANT, pas après');
|
|
|
|
await tester.tap(find.text('Annuler'));
|
|
await tester.pumpAndSettle();
|
|
expect(appels, 0);
|
|
});
|
|
|
|
testWidgets('confirmé, il écrit une fois', (tester) async {
|
|
var appels = 0;
|
|
await tester.pumpWidget(_harness(
|
|
live: _v2c(apiMode: 'ChargingModeEco'),
|
|
onSetMode: (_) async {
|
|
appels++;
|
|
return null;
|
|
},
|
|
));
|
|
|
|
await tester.tap(find.text('Manuel'));
|
|
await tester.pumpAndSettle();
|
|
await tester.tap(find.text('Passer en manuel'));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(appels, 1);
|
|
});
|
|
|
|
testWidgets('déjà en manuel, aucune question — rien ne change', (tester) async {
|
|
// Le contrôle qui MORD : la confirmation ne doit pas se déclencher sur un ré-appui
|
|
// du mode déjà en place, sinon elle devient un péage qu'on apprend à cliquer sans
|
|
// lire — et une confirmation qu'on ne lit plus ne protège plus de rien.
|
|
var appels = 0;
|
|
await tester.pumpWidget(_harness(
|
|
live: _v2c(), // déjà ChargingModeNormal
|
|
onSetMode: (_) async {
|
|
appels++;
|
|
return null;
|
|
},
|
|
));
|
|
|
|
await tester.tap(find.text('Manuel'));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.textContaining('Rendre la main'), findsNothing);
|
|
expect(appels, 1);
|
|
});
|
|
|
|
testWidgets('les autres modes ne demandent rien', (tester) async {
|
|
var appels = 0;
|
|
await tester.pumpWidget(_harness(
|
|
live: _v2c(),
|
|
onSetMode: (_) async {
|
|
appels++;
|
|
return null;
|
|
},
|
|
));
|
|
|
|
await tester.tap(find.text('Min+PV'));
|
|
await tester.pumpAndSettle();
|
|
expect(find.textContaining('Rendre la main'), findsNothing);
|
|
expect(appels, 1);
|
|
});
|
|
});
|
|
}
|