etm-powersync-app/test/load_mechanism_ev_test.dart
Patrick Schurig 5a51582729 fix(ev): « Boost » devient « Manuel » — le nom promettait le contraire du comportement
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
2026-09-08 12:19:39 +02:00

285 lines
12 KiB
Dart

/// L'écran « Configurer » d'une BORNE — deux défauts trouvés sur téléphone, pas en test.
///
/// Le 2026-08-28, app installée en release contre `.75` :
///
/// 1. l'écran **refusait d'enregistrer** une borne — « La puissance nominale est requise et
/// doit être supérieure à 0 W » — alors que sa propre carte « Mécanisme » explique deux
/// blocs plus haut qu'une borne n'en porte pas. `_valider()` faisait tomber
/// `adapter: evcharger` dans la branche des charges modulables ;
/// 2. il proposait une **sonde de température** sur une borne de recharge. Le champ est
/// générique au schéma (`sensorThingId` s'écrit sur n'importe quelle charge), mais le
/// moteur ne le lit que pour publier une température — sur une borne, il ne mène nulle
/// part.
///
/// Aucun des 164 tests ne pouvait les voir : le premier vit dans une méthode privée de
/// widget, le second est une question de sens, pas de valeur.
library;
import 'dart:convert';
import 'dart:io';
import 'package:etm_powersync_app/l10n/app_localizations.dart';
import 'package:etm_powersync_app/providers/load_config_provider.dart';
import 'package:etm_powersync_app/providers/load_telemetry_provider.dart';
import 'package:etm_powersync_app/providers/schema_provider.dart';
import 'package:etm_powersync_app/screens/energy/load_mechanism_screen.dart';
import 'package:etm_powersync_app/models/nymea_models.dart';
import 'package:etm_powersync_app/services/nymea_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
/// Configuration réelle du banc : chauffe-eau (relais), PAC (SG-Ready), trois bornes.
List<Map<String, dynamic>> _banc() {
final raw = jsonDecode(
File('test/fixtures/loadconfig_hems75_v2c.json').readAsStringSync());
return ((raw as Map<String, dynamic>)['loadConfigs'] as List)
.cast<Map<String, dynamic>>();
}
const kV2c = '{4d1adafd-cafc-4813-93b1-ca8933ce71ab}';
/// La V2C telle que `.75` la publie : classe `trydan` portant l'interface `evcharger`, et
/// les trois états que la tuile et l'écran lisent.
NymeaService _serviceAvecBorne({String mode = 'ChargingModeNormal'}) {
const classId = 'cls-trydan';
final svc = NymeaService()
..adoptThingsForTest(
[
NymeaThing(
id: '4d1adafd-cafc-4813-93b1-ca8933ce71ab',
name: 'V2C Trydan',
thingClassId: classId,
setupStatus: 'ThingSetupStatusComplete',
paramValues: const [],
states: const [
NymeaStateValue(stateTypeId: 'st-plugged', value: false),
NymeaStateValue(stateTypeId: 'st-charging', value: false),
NymeaStateValue(stateTypeId: 'st-power', value: 0),
],
),
],
[
const NymeaThingClass(
id: classId,
name: 'trydan',
displayName: 'Trydan',
interfaces: ['evcharger'],
stateTypes: [
NymeaStateType(
id: 'st-plugged',
name: 'pluggedIn',
displayName: 'Branché',
type: 'Bool',
unit: ''),
NymeaStateType(
id: 'st-charging',
name: 'charging',
displayName: 'En charge',
type: 'Bool',
unit: ''),
NymeaStateType(
id: 'st-power',
name: 'currentPower',
displayName: 'Puissance',
type: 'Double',
unit: 'W'),
],
),
],
);
svc.adoptChargingInfosForTest([
{
'evChargerId': '4d1adafd-cafc-4813-93b1-ca8933ce71ab',
'chargingMode': mode,
'chargingState': 'ChargingStateIdle',
},
]);
return svc;
}
Widget _harness(String loadId,
{List<Map<String, dynamic>>? config,
NymeaService? service,
Map<String, dynamic>? telemetry}) =>
MultiProvider(
providers: [
ChangeNotifierProvider.value(
value: LoadConfigProvider()..adoptForTest(config ?? _banc())),
ChangeNotifierProvider.value(
value: LoadTelemetryProvider()
..adoptForTest(
telemetry ?? {'degradedMode': false, 'loads': const []})),
ChangeNotifierProvider.value(value: SchemaProvider()),
ChangeNotifierProvider.value(value: service ?? NymeaService()),
],
// Les délégués de localisation sont indispensables : `_Identite` lit `context.l10n`
// dès sa construction, et sans eux l'écran explose avant d'afficher quoi que ce soit.
child: MaterialApp(
locale: const Locale('fr'),
localizationsDelegates: L10n.localizationsDelegates,
supportedLocales: L10n.supportedLocales,
home: LoadMechanismScreen(loadId: loadId),
),
);
/// Grande surface : l'écran est un `ListView`, qui ne construit QUE ce qui entre dans son
/// viewport. Sur 600 px de haut, le compteur, la sonde et la barre d'enregistrement ne
/// sont jamais bâtis — et `find` ne trouve rien, ce qui se lit comme un succès quand on
/// cherche une absence. C'est le piège exact que ce fichier éprouve ailleurs.
Future<void> _poser(WidgetTester tester, Widget w) async {
// 8000 px : les écrans relais et SG-Ready sont bien plus hauts que celui d'une borne,
// et le compteur y tombait hors du viewport — donc jamais construit, donc « absent »
// pour `find`. Une absence qui n'en est pas une : le piège que ce fichier traque.
await tester.binding.setSurfaceSize(const Size(1000, 8000));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(w);
await tester.pump();
}
void main() {
testWidgets('une BORNE ne réclame aucune puissance nominale', (tester) async {
await _poser(tester, _harness(kV2c));
// Le blocage qui empêchait d'enregistrer un simple renommage.
expect(find.textContaining('puissance nominale est requise'), findsNothing);
// Et l'écran dit toujours POURQUOI elle n'en a pas.
expect(find.textContaining('ni contacts, ni paliers'), findsOneWidget);
});
testWidgets('une charge MODULABLE, elle, la réclame toujours', (tester) async {
// Contrôle négatif : la règle n'est pas supprimée, elle est rendue à son périmètre.
final cfg = [
for (final c in _banc())
if (c['id'] == 'chauffe-eau')
{
...c,
'adapter': 'etmvariableload',
'relays': const [],
'maxPowerW': 0,
}
else
c,
];
await _poser(tester, _harness('chauffe-eau', config: cfg));
expect(find.textContaining('puissance nominale est requise'), findsOneWidget);
});
testWidgets('pas de sonde de température sur une borne — le compteur reste',
(tester) async {
await _poser(tester, _harness(kV2c));
expect(find.text('Sonde de température'), findsNothing);
// Le compteur, lui, garde un sens : c'est le seul chemin par lequel une mesure
// apparaît, et une borne peut se désigner elle-même (elle publie `currentPower`).
expect(find.text('Compteur dédié'), findsOneWidget);
// Hors télémétrie, la box ne dit pas d'où viendrait la mesure : l'écran ne le
// devine pas non plus.
expect(find.textContaining('pas dans la télémétrie du dernier cycle'),
findsOneWidget);
});
testWidgets('sur une charge thermique, la sonde est bien là', (tester) async {
await _poser(tester, _harness('chauffe-eau'));
expect(find.text('Sonde de température'), findsOneWidget);
});
testWidgets('l\'écran n\'envoie plus vers une carte véhicule supprimée',
(tester) async {
// Elle renvoyait au « tableau de bord », où la carte a été supprimée le 2026-08-28 :
// l'installateur y aurait cherché un réglage qui n'y est pas.
await _poser(tester, _harness(kV2c));
expect(find.textContaining('tableau de bord'), findsNothing);
expect(find.textContaining('tuile « Borne EV » des favoris'), findsOneWidget);
});
testWidgets('les trois modes de recharge sont SUR CET ÉCRAN', (tester) async {
// Ils vivaient aux favoris ; « Configurer » est l'écran de mise en service.
await _poser(tester,
_harness(kV2c, service: _serviceAvecBorne()));
expect(find.widgetWithText(OutlinedButton, 'PV'), findsOneWidget);
expect(find.widgetWithText(OutlinedButton, 'Min+PV'), findsOneWidget);
expect(find.widgetWithText(OutlinedButton, 'Manuel'), findsOneWidget);
// Le mode LU est affiché à côté, et c'est celui de la box.
expect(find.textContaining('lu : Manuel'), findsOneWidget);
// Et l'avertissement d'une borne neuve, là où on la met en service.
expect(find.textContaining('hors arbitrage'), findsOneWidget);
});
testWidgets('sans ChargingInfo lue, aucun bouton — on ne devine pas un mode',
(tester) async {
// Service sans borne connue : l'écran doit le DIRE, pas allumer un bouton au hasard.
await _poser(tester, _harness(kV2c));
expect(find.widgetWithText(OutlinedButton, 'PV'), findsNothing);
expect(find.textContaining('Mode de recharge non lu'), findsOneWidget);
});
testWidgets('un mode éco fait taire l\'avertissement, les boutons restent',
(tester) async {
await _poser(
tester,
_harness(kV2c,
service: _serviceAvecBorne(mode: 'ChargingModeEco')));
expect(find.textContaining('hors arbitrage'), findsNothing);
expect(find.widgetWithText(OutlinedButton, 'PV'), findsOneWidget);
expect(find.textContaining('lu : PV'), findsOneWidget);
});
group('le compteur dédié se décide sur la SOURCE, jamais sur l\'adaptateur', () {
/// Une trame où la charge visée publie la source donnée.
Map<String, dynamic> tel(String id, String source) => {
'degradedMode': false,
'loads': [
{
'loadId': id,
'allocatedW': 3000,
'available': true,
'decision': {'code': 'SURPLUS_SETPOINT', 'params': const {}},
'measurement': source == 'none'
? {'source': 'none'}
: {'source': source, 'powerW': 2950.0},
}
],
};
testWidgets('source `device` : le compteur passe en réglage avancé, replié',
(tester) async {
await _poser(tester, _harness(kV2c, telemetry: tel(kV2c, 'device')));
// La carte reste — replier n'est pas masquer, et un réglage caché pour de bon
// finit par être réinventé ailleurs.
expect(find.text('Compteur dédié'), findsOneWidget);
expect(find.textContaining('publie déjà sa puissance'), findsOneWidget);
// Mais son contenu n'est pas déployé : ni le sélecteur, ni les aides.
expect(find.textContaining('n\'entre pas dans le budget'), findsNothing);
});
testWidgets('source `none` : le compteur est PROPOSÉ, visible', (tester) async {
// Le cas d'une PAC SG-Ready — `none` par construction (LM-1105-b) — et celui d'une
// topologie de relais mixte. Masquer le champ y couperait la seule voie de mesure.
await _poser(
tester, _harness('pac-terrain', telemetry: tel('pac-terrain', 'none')));
expect(find.text('Compteur dédié'), findsOneWidget);
// Carte DÉPLOYÉE : son contenu est monté, pas seulement son titre.
expect(find.textContaining('n\'entre pas dans le budget'), findsOneWidget);
expect(find.textContaining('seule voie vers une mesure'), findsOneWidget);
});
testWidgets('source `meter` : visible aussi, et l\'écran dit ce qu\'elle prouve',
(tester) async {
await _poser(tester,
_harness('chauffe-eau', telemetry: tel('chauffe-eau', 'meter')));
expect(find.textContaining('n\'entre pas dans le budget'), findsOneWidget);
expect(find.textContaining('constitue une preuve'), findsOneWidget);
});
});
}