etm-powersync-app/test/merged_config_screen_test.dart
Patrick Schurig 51a1ab929c feat(config): retirer une charge orpheline, et régler le mode de recharge à un seul endroit
Deux manques trouvés en usage réel sur téléphone, contre .75.

── 1. Retirer une charge dont l'appareil n'existe plus ────────────────────────

Le plugin crée d'office une entrée par borne détectée mais ne la retire pas quand le Thing
s'en va : la Terra AC supprimée de nymea tenait encore le rang 4 deux jours plus tard, et
l'app savait le dire sans savoir y remédier.

Le bouton n'apparaît que sur THING_MISSING. Sur une charge vivante le geste n'aurait pas de
sens — le plugin recrée l'entrée au cycle suivant, et un bouton qui se défait tout seul
ferait douter de la box.

Le chemin est celui qui était demandé, et pour la raison qui était donnée :
  · LIRE (GetLoadConfig, frais — pas l'état en mémoire, la box a pu changer d'avis)
  · PATCHER (payloadWithoutLoad : les autres charges recopiées VERBATIM, champ par champ)
  · RÉÉCRIRE (SetLoadConfig, qui remplace l'ensemble)

Jamais une reconstruction depuis le modèle typé : un champ qu'une box plus récente porterait
et que l'app ne connaît pas serait perdu pour TOUTES les charges d'un coup, en silence. Les
rangs ne sont pas renumérotés — le trou laissé (1,2,3,5) ne change l'ordre de personne,
alors qu'une renumérotation réécrirait le rang de charges que personne n'a touchées.

Rien à moitié écrit : un refus laisse la configuration locale intacte (aucune adoption avant
confirmation) et la box refuse l'ensemble ou l'accepte en entier. Le geste est refusé tant
qu'un brouillon est en cours — sinon la suppression emporterait des modifications non
enregistrées. La confirmation annonce ce que l'utilisateur ne peut pas deviner : retirer une
charge réécrit toutes les autres.

── 2. Le mode de recharge, un seul lieu d'écriture ───────────────────────────

Les trois boutons quittent la tuile des favoris pour l'écran « Configurer » de la borne :
c'est l'écran de mise en service, celui où on découvre qu'une borne neuve est en
ChargingModeNormal. Deux endroits qui écrivent SetChargingInfo seraient deux comportements
qui divergent.

La tuile des favoris garde la LECTURE et l'avertissement « hors arbitrage » : c'est le
client qui ouvre ses favoris, et il doit voir que sa borne n'est pas pilotée même s'il doit
aller ailleurs pour le corriger.

⚠️ Bug latent trouvé en déplaçant : setChargingInfo écrivait toujours sur la PREMIÈRE borne
configurée (_findEvChargerId). Sur le banc qui en porte trois, régler la V2C depuis son
propre écran aurait réglé la wallbox simulée. La borne est maintenant visée explicitement,
et evChargerFor() réconcilie les deux graphies d'identifiant (§9 du contrat de données).

181 tests (+12), flutter analyze inchangé à 27 remarques.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f
2026-08-28 14:36:50 +02:00

451 lines
20 KiB
Dart

/// L'écran de configuration FUSIONNÉ, éprouvé sur les dumps **réels** de `.75`.
///
/// > ⚠️ **Ce sont des tests de widgets, pas une vérification machine.** Ils montent les
/// > vrais widgets sur les vraies charges utiles de la box (`test/fixtures/*_v3.json`,
/// > plugin `1.15.2+etm15`), ce qui couvre le rendu et les écritures préparées — mais
/// > **aucune** ne part vers `.75` ici. Ce que ces tests ne prouvent pas est écrit dans
/// > le journal de session : l'IU n'a pas pu être pilotée sur l'appareil, l'injection
/// > d'événements ADB étant refusée par MIUI.
library;
import 'dart:convert';
import 'dart:io';
import 'package:etm_powersync_app/l10n/app_localizations.dart';
import 'package:etm_powersync_app/models/load_config_entry.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/services/load_hardware.dart';
import 'package:etm_powersync_app/services/nymea_service.dart';
import 'package:etm_powersync_app/services/rpc_schema.dart';
import 'package:etm_powersync_app/widgets/controllable_loads_section.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
Map<String, dynamic> _fixture(String name) =>
jsonDecode(File('test/fixtures/$name').readAsStringSync())
as Map<String, dynamic>;
/// Config réelle de `.75`, avec les domaines tels qu'ils y sont posés aujourd'hui
/// (`chauffe-eau → ecs`, `pac-terrain → hvac`) — le multi-domaines du banc.
List<Map<String, dynamic>> _benchConfig() {
final raw = (_fixture('loadconfig_hems75_v3.json')['loadConfigs'] as List)
.cast<Map<String, dynamic>>();
return [
for (final c in raw)
{...c, 'domain': c['id'] == 'chauffe-eau' ? 'ecs' : 'hvac'},
];
}
Widget _harness({
required LoadConfigProvider config,
required LoadTelemetryProvider telemetry,
NymeaService? service,
}) =>
MultiProvider(
providers: [
ChangeNotifierProvider.value(value: config),
ChangeNotifierProvider.value(value: telemetry),
// La section « Recharge véhicule » lit les Things de la box pour savoir s'il y a
// une borne. Hors connexion, la liste est vide et la section s'efface — c'est
// exactement le comportement attendu sur une installation sans borne.
ChangeNotifierProvider.value(value: service ?? NymeaService()),
],
child: MaterialApp(
locale: const Locale('fr'),
localizationsDelegates: L10n.localizationsDelegates,
supportedLocales: L10n.supportedLocales,
home: Scaffold(
body: SingleChildScrollView(
child: ControllableLoadsSection(onConfigure: (_, __) {}),
),
),
),
);
void main() {
group('fusion — la liste vient de GetLoadConfig, pas des rôles', () {
testWidgets('les deux charges du banc apparaissent, chacune dans son domaine',
(tester) async {
final config = LoadConfigProvider()..adoptForTest(_benchConfig());
final telemetry = LoadTelemetryProvider()
..adoptForTest(_fixture('loadtelemetry_hems75_v3.json')['telemetry']
as Map<String, dynamic>);
await tester.pumpWidget(_harness(config: config, telemetry: telemetry));
await tester.pump();
// Les identifiants viennent de la box — pas d'une énumération figée de rôles.
expect(find.text('chauffe-eau'), findsWidgets);
expect(find.text('PAC banc'), findsWidgets);
// Deux mécanismes distincts, deux écrans de détail : un bouton par charge.
expect(find.text('Configurer'), findsNWidgets(2));
});
testWidgets('une charge désactivée se lit « désactivée », jamais « perdue »',
(tester) async {
final cfg = _benchConfig();
final desactivee = [
for (final c in cfg)
if (c['id'] == 'pac-terrain') {...c, 'enabled': false} else c,
];
final config = LoadConfigProvider()..adoptForTest(desactivee);
// Télémétrie VIDE : c'est exactement le cas d'une charge hors arbitrage.
final telemetry = LoadTelemetryProvider()
..adoptForTest({'degradedMode': false, 'loads': const []});
await tester.pumpWidget(_harness(config: config, telemetry: telemetry));
await tester.pump();
expect(find.textContaining('Désactivée'), findsWidgets);
expect(find.textContaining('perdue'), findsNothing);
});
});
group('annonce d\'impact avant écriture', () {
test('réordonner des contacteurs de puissances distinctes n\'avertit de RIEN', () {
// Inversé par le moteur en `+etm16` : la comparaison porte sur la TABLE de paliers,
// pas sur la liste. Même table → aucune reconstruction → rien à annoncer. Avertir
// ici reviendrait à effrayer sur un geste devenu gratuit.
final config = LoadConfigProvider()..adoptForTest(_benchConfig());
final avant = config.entries.firstWhere((e) => e.id == 'chauffe-eau');
final inverse = avant.patched(relays: [
for (final r in avant.relays.reversed)
{'thingId': r.thingId, 'powerW': r.powerW}
]);
config.replaceEntry('chauffe-eau', inverse);
expect(config.hasPendingChanges, isTrue);
expect(config.pendingNeedsWarning, isFalse,
reason: 'les trois relais ont des puissances distinctes : même table');
});
test('changer la puissance d\'un contacteur avertit — la table change', () {
final config = LoadConfigProvider()..adoptForTest(_benchConfig());
final avant = config.entries.firstWhere((e) => e.id == 'chauffe-eau');
config.replaceEntry(
'chauffe-eau',
avant.patched(relays: [
{'thingId': avant.relays[0].thingId, 'powerW': 800},
for (final r in avant.relays.skip(1))
{'thingId': r.thingId, 'powerW': r.powerW}
]),
);
expect(config.pendingNeedsWarning, isTrue);
final impact = config
.pendingImpact()
.firstWhere((i) => i.loadId == 'chauffe-eau');
expect(impact.impact, LoadChangeImpact.rebuild);
expect(impact.changedFields, ['relays']);
});
test('renommer ou reclasser n\'avertit de rien', () {
final config = LoadConfigProvider()..adoptForTest(_benchConfig());
config.setLabel('chauffe-eau', 'Ballon 200 L');
expect(config.hasPendingChanges, isTrue);
expect(config.pendingNeedsWarning, isFalse);
});
test('désactiver la PAC avertit — elle passe par son état sûr', () {
final config = LoadConfigProvider()..adoptForTest(_benchConfig());
config.setEnabled('pac-terrain', false);
final impact =
config.pendingImpact().firstWhere((i) => i.loadId == 'pac-terrain');
expect(impact.impact, LoadChangeImpact.removed);
expect(impact.needsWarning, isTrue);
});
});
group('grisage dérivé du schéma RÉEL de .75', () {
late RpcSchema schema;
setUp(() {
final f = _fixture('schema_hems75_etm15.json');
schema = RpcSchema.fromIntrospect(f, hello: {
'version': f['_hello_version'],
'experiences': [
for (final e in (f['_hello_experiences'] as Map).entries)
{'name': e.key, 'version': e.value},
],
});
});
test('le schéma se lit, et les versions d\'API avec lui', () {
expect(schema.loaded, isTrue);
expect(schema.experienceVersions['NymeaEnergy'], '0.8');
// La version du PAQUET (+etm15) n'est PAS sur le fil : ce qu'on annonce est
// la version d'API, qui décrit le contrat.
expect(schema.versionLineContains('etm15'), isFalse);
});
test('ChargingInfo : les champs d\'échéance existent, minCurrent NON', () {
expect(schema.typeSupport('ChargingInfo', ['targetPercentage']),
FieldSupport.writable);
expect(schema.typeSupport('ChargingInfo', ['endDateTime']),
FieldSupport.writable);
// Le mode existe, son paramètre non — c'est ce que l'écran doit dire.
expect(schema.typeSupport('ChargingInfo', ['minCurrent']),
FieldSupport.absent);
expect(schema.enumValues('ChargingMode'),
contains('ChargingModeEcoWithMinCurrent'));
});
test('ChargingInfo : chargingState est une MESURE, pas un champ manquant', () {
expect(schema.typeSupport('ChargingInfo', ['chargingState']),
FieldSupport.readOnly);
});
test('SetLoadConfig : les charges utiles de mécanisme sont au schéma', () {
for (final champ in ['relays', 'sgReady', 'maxPowerW', 'domain']) {
expect(
schema.paramSupport('NymeaEnergy.SetLoadConfig',
['loadConfigs', '[]', champ]),
FieldSupport.writable,
reason: '$champ doit être écrivable sur .75',
);
}
});
test('AirConditioning est pleinement pilotable — il n\'y a rien à griser', () {
for (final m in [
'AirConditioning.AddZone',
'AirConditioning.SetZoneThings',
'AirConditioning.SetZoneWeekSchedule',
'AirConditioning.SetZoneSetpointOverride',
]) {
expect(schema.hasMethod(m), isTrue, reason: '$m est publiée par .75');
}
});
test('un schéma NON LU ne dit pas « absent » — il dit « je ne sais pas »', () {
expect(RpcSchema.unknown.typeSupport('ChargingInfo', ['targetPercentage']),
FieldSupport.unknown);
expect(RpcSchema.unknown.paramSupport('NymeaEnergy.SetLoadConfig', ['x']),
FieldSupport.unknown);
});
});
group('retirer une charge — seulement quand l\'appareil a disparu', () {
List<Map<String, dynamic>> v2cConfig() =>
(_fixture('loadconfig_hems75_v2c.json')['loadConfigs'] as List)
.cast<Map<String, dynamic>>();
LoadTelemetryProvider tel() => LoadTelemetryProvider()
..adoptForTest(_fixture('loadtelemetry_hems75_thingmissing.json')['telemetry']
as Map<String, dynamic>);
testWidgets('aucune charge SAINE ne propose la suppression', (tester) async {
// Sur une charge vivante le geste n'aurait pas de sens : le plugin recrée d'office
// l'entrée d'une borne détectée au cycle suivant, et un bouton qui se défait tout
// seul fait douter de la box.
final config = LoadConfigProvider()..adoptForTest(v2cConfig());
await tester.pumpWidget(_harness(config: config, telemetry: tel()));
await tester.pump();
// Une seule charge est en défaut d'appareil sur cette trame : un seul bouton.
expect(find.text('Retirer cette charge'), findsOneWidget);
});
testWidgets('la confirmation annonce que TOUTE la configuration est réécrite',
(tester) async {
// C'est la seule chose que l'utilisateur ne peut pas deviner de l'écran :
// `SetLoadConfig` remplace l'ensemble, donc retirer une charge réécrit les autres.
final config = LoadConfigProvider()..adoptForTest(v2cConfig());
await tester.pumpWidget(_harness(config: config, telemetry: tel()));
await tester.pump();
// Le bouton vit en bas d'une liste de cinq cartes : sans `ensureVisible`, la touche
// tombe hors écran et ne fait rien — en silence.
await tester.ensureVisible(find.text('Retirer cette charge'));
await tester.pumpAndSettle();
await tester.tap(find.text('Retirer cette charge'));
await tester.pumpAndSettle();
expect(find.text('Retirer cette charge ?'), findsOneWidget);
expect(find.textContaining('réécrit TOUTE la configuration'), findsOneWidget);
// Le nom est dans la confirmation — et aussi sur la carte derrière, d'où `findsN`.
expect(find.textContaining('Terra AC'), findsNWidgets(2));
// Annuler ne touche à rien.
await tester.tap(find.text('Annuler'));
await tester.pumpAndSettle();
expect(config.entries.length, 5);
expect(find.text('Retirer cette charge ?'), findsNothing);
});
});
// ───────────────────────────────────────────────────────────────────────────
group('une borne à pleine puissance n\'est pas arbitrée — et l\'écran le nomme', () {
List<Map<String, dynamic>> v2cConfig() =>
(_fixture('loadconfig_hems75_v2c.json')['loadConfigs'] as List)
.cast<Map<String, dynamic>>();
/// `GetChargingInfos` du banc, 2026-08-28 : les deux bornes appairées y sont en
/// `ChargingModeNormal` / `ChargingStateIdle`. **L'`evChargerId` n'a pas d'accolades**,
/// là où l'id de charge de `GetLoadConfig` en a — c'est le piège que
/// `chargingModeOf` réconcilie.
NymeaService service({String mode = 'ChargingModeNormal'}) =>
NymeaService()
..adoptChargingInfosForTest([
{
'evChargerId': '4d1adafd-cafc-4813-93b1-ca8933ce71ab',
'chargingMode': mode,
'chargingState': 'ChargingStateIdle',
},
]);
testWidgets('la note est à l\'écran sur la borne en ChargingModeNormal',
(tester) async {
final config = LoadConfigProvider()..adoptForTest(v2cConfig());
final telemetry = LoadTelemetryProvider()
..adoptForTest({'degradedMode': false, 'loads': const []});
await tester.pumpWidget(_harness(
config: config, telemetry: telemetry, service: service()));
await tester.pump();
// Une seule des cinq charges est concernée : la V2C. Les deux autres bornes n'ont
// pas d'entrée dans la charge utile lue, et une absence ne se conclut pas.
expect(find.textContaining('hors arbitrage'), findsOneWidget);
expect(find.textContaining('choisir PV ou Min+PV'), findsOneWidget);
});
testWidgets('un mode éco fait taire la note', (tester) async {
final config = LoadConfigProvider()..adoptForTest(v2cConfig());
final telemetry = LoadTelemetryProvider()
..adoptForTest({'degradedMode': false, 'loads': const []});
await tester.pumpWidget(_harness(
config: config,
telemetry: telemetry,
service: service(mode: 'ChargingModeEcoWithMinCurrent')));
await tester.pump();
expect(find.textContaining('hors arbitrage'), findsNothing);
});
testWidgets('⚠️ les accolades de l\'id de charge ne doivent PAS faire rater la borne',
(tester) async {
// `GetLoadConfig` rend `{4d1adafd-…}`, `GetChargingInfos` rend `4d1adafd-…`. Comparer
// les deux tels quels ne rend jamais rien — en silence : l'écran conclurait « mode
// non lu » sur une borne parfaitement lue, et la note ne sortirait jamais.
final svc = service();
expect(svc.chargingModeOf('{4d1adafd-cafc-4813-93b1-ca8933ce71ab}'),
'ChargingModeNormal');
expect(svc.chargingModeOf('4d1adafd-cafc-4813-93b1-ca8933ce71ab'),
'ChargingModeNormal');
expect(svc.chargingModeOf('{09e520fd-d472-47c4-8887-6e7759372889}'), isNull,
reason: 'une borne sans entrée lue n\'a pas de mode — pas un mode par défaut');
});
});
// ───────────────────────────────────────────────────────────────────────────
// Les deux notes de la configuration réelle du 2026-08-28 — MONTÉES, pas
// seulement calculées.
//
// `tiedByPriority` et `GroupedOrder.isContiguous` ont leurs tests de fonction dans
// `evcharger_config_test.dart`, et ils passaient déjà le 2026-08-27 : la contiguïté
// était calculée, exposée par le provider, et **affichée nulle part**. Un test de
// fonction ne pouvait pas le voir. Ceux-ci montent l'écran et cherchent le texte.
group('terrain 2026-08-28 — les notes s\'affichent VRAIMENT', () {
List<Map<String, dynamic>> v2cConfig() =>
(_fixture('loadconfig_hems75_v2c.json')['loadConfigs'] as List)
.cast<Map<String, dynamic>>();
testWidgets('la note de rangs ex æquo est à l\'écran, et elle nomme les charges',
(tester) async {
final config = LoadConfigProvider()..adoptForTest(v2cConfig());
final telemetry = LoadTelemetryProvider()
..adoptForTest({'degradedMode': false, 'loads': const []});
await tester.pumpWidget(_harness(config: config, telemetry: telemetry));
await tester.pump();
final note = find.textContaining('Rangs à égalité');
expect(note, findsOneWidget);
// Nommer les charges est le point : « des rangs sont à égalité » sans dire
// lesquelles laisse l'installateur chercher dans cinq cartes.
final texte = tester.widget<Text>(note).data!;
expect(texte, contains('chauffe-eau'));
expect(texte, contains('V2C Trydan'));
});
testWidgets('la note d\'ordre entrelacé est à l\'écran, et annonce l\'effet de l\'enregistrement',
(tester) async {
final config = LoadConfigProvider()..adoptForTest(v2cConfig());
final telemetry = LoadTelemetryProvider()
..adoptForTest({'degradedMode': false, 'loads': const []});
await tester.pumpWidget(_harness(config: config, telemetry: telemetry));
await tester.pump();
final note = find.textContaining('n\'est pas groupé par domaine');
expect(note, findsOneWidget);
// Ce que l'enregistrement fera doit être dit AVANT, pas constaté après.
expect(tester.widget<Text>(note).data!, contains('REGROUPERA'));
});
testWidgets('un ordre déjà groupé et classé ne montre AUCUNE des deux notes',
(tester) async {
// Le contrôle négatif : les deux notes doivent disparaître d'elles-mêmes au
// premier vrai réglage, sinon elles deviennent du décor qu'on n'ouvre plus.
final config = LoadConfigProvider()..adoptForTest(_benchConfig());
final telemetry = LoadTelemetryProvider()
..adoptForTest({'degradedMode': false, 'loads': const []});
await tester.pumpWidget(_harness(config: config, telemetry: telemetry));
await tester.pump();
expect(find.textContaining('Rangs à égalité'), findsNothing);
expect(find.textContaining('n\'est pas groupé par domaine'), findsNothing);
});
testWidgets('THING_MISSING se lit « appareil absent », et n\'offre RIEN à lever',
(tester) async {
// Télémétrie CAPTURÉE sur `.75` le 2026-08-28 à 10:29 UTC — la réserve de la veille
// est levée par la mesure. La box publie bien `THING_MISSING` sur cette charge :
// `available: false`, `decision: LOAD_UNAVAILABLE {frozenW: 0}`,
// `mechanism.kind: evcharger`.
//
// Le masquage relevé mardi côté moteur (`etmvariableloadadapter.cpp:148-150`,
// `m_faulted` testé avant que `THING_MISSING` ne puisse s'exprimer) concerne
// l'adaptateur des charges **variables** — relais et SG-Ready. L'adaptateur
// `evcharger` n'en souffre pas, et c'est pour ça que le code sort ici.
final telemetry = LoadTelemetryProvider()
..adoptForTest(_fixture('loadtelemetry_hems75_thingmissing.json')['telemetry']
as Map<String, dynamic>);
final config = LoadConfigProvider()..adoptForTest(v2cConfig());
await tester.pumpWidget(_harness(config: config, telemetry: telemetry));
await tester.pump();
// Le bouton ne peut pas tenir sa promesse — il n'existe pas.
expect(find.text('Lever le défaut'), findsNothing);
expect(find.textContaining('La levée est une action délibérée'), findsNothing);
// À la place : les deux gestes qui, eux, résolvent le cas.
expect(find.textContaining('Réinstallez-le dans nymea'), findsOneWidget);
// Et le libellé ne doit pas se lire « votre configuration est cassée ».
expect(find.textContaining('n\'existe plus dans nymea'), findsOneWidget);
// Le geste qui, lui, résout le cas : retirer l'entrée orpheline.
expect(find.text('Retirer cette charge'), findsOneWidget);
// Les deux autres bornes du banc sont ABSENTES de la télémétrie captée — aucun
// véhicule branché (`+etm24`). Elles restent affichées comme charges configurées,
// et aucune ne porte de défaut : « hors arbitrage » n'est pas « en panne ».
expect(find.text('V2C Trydan'), findsWidgets);
expect(find.textContaining('En défaut'), findsOneWidget);
});
});
}
extension on RpcSchema {
bool versionLineContains(String s) =>
(nymeaVersion ?? '').contains(s) ||
experienceVersions.values.any((v) => v.contains(s));
}