etm-powersync-app/test/merged_config_screen_test.dart
Patrick Schurig e0264b5ed6 feat(ev): une borne à pleine puissance n'est pas arbitrée — les deux écrans le nomment
Geste de mise en service, signalé par le moteur : toute borne détectée reçoit d'office
ChargingModeNormal, c'est-à-dire pleine puissance HORS arbitrage. Une borne neuve n'est
donc jamais pilotée tant que personne n'a posé un mode éco, et rien ne le disait —
l'installateur pose une borne, elle charge à fond, et il cherche du côté du HEMS un défaut
qui n'existe pas.

La note apparaît aux deux endroits qui comptent, mot pour mot (kEvUnmanagedNote) :
  · la carte de configuration, celle qu'on ouvre à la mise en service ;
  · la tuile des favoris, où les trois boutons de mode sont juste à côté.

⚠️ Elle nomme l'ÉTAT, pas une négligence. La box publie le même chargingMode pour un boost
délibéré et pour une borne jamais réglée : l'app ne peut pas les distinguer, donc elle dit
« pleine puissance, hors arbitrage » et rappelle que c'est aussi le mode d'office. Même
règle que la note de rangs ex æquo — reproductible n'est pas choisi, et on ne fait pas dire
à un écran ce que la donnée ne porte pas. Un mode NON LU ne déclenche rien non plus : on ne
conclut pas d'une absence.

Le piège qui aurait rendu la note invisible est couvert par un test : GetLoadConfig rend
l'id de charge AVEC accolades ({4d1adafd-…}), GetChargingInfos rend l'evChargerId SANS.
Comparer les deux tels quels ne rend jamais rien — en silence : l'écran aurait conclu
« mode non lu » sur une borne parfaitement lue. chargingModeOf() réconcilie les deux.

Six tests montés (trois par écran), dont les deux contrôles négatifs.

Et la réserve sessionEnergy est documentée à sa juste place : le champ est CONSTRUIT par
le plugin V2C 1.15.2+etm13 — il accumule les incréments de chargeEnergy, détecte les
remises à zéro, ne repart qu'à l'ouverture d'une session — donc l'égalité observée hier
s'explique d'elle-même. La réserve tient jusqu'à la trace que la campagne produira
(chargeEnergy à zéro, sessionEnergy qui continue) : aucun avancement affiché d'ici là.

Noté aussi, pour la suite : sessionEnergy n'est PAS historisé (suggestLogging écrasé par
things.conf sur un Thing déjà appairé ; la V2C n'y a que connected, charging, power). La
valeur courante est juste, il n'y aura pas de courbe — le choix des états enregistrés est
côté app, entrée au TODO.

164 tests, 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 13:08:01 +02:00

398 lines
18 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('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);
// 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));
}