etm-powersync-app/test/merged_config_screen_test.dart
Patrick Schurig 935cec269f test(config): les deux notes sont MONTÉES, pas seulement calculées
Vérification demandée après le relevé V2C du banc : « ta note de rangs ex æquo doit
s'afficher — vérifie qu'elle le fait ». Elle s'affiche. Mais rien ne le prouvait, et
c'est exactement le défaut qui a coûté isNotGrouped : la contiguïté était calculée
depuis le premier jour, testée en fonction, exposée par le provider — et affichée nulle
part. Un test de fonction ne peut pas voir ça.

Quatre tests de widgets sur la configuration réelle du 2026-08-28
(loadconfig_hems75_v2c.json), montés dans le harnais existant :

  · la note de rangs ex æquo est à l'écran ET nomme les charges (chauffe-eau, V2C
    Trydan) — « des rangs sont à égalité » sans dire lesquelles laisse chercher dans
    cinq cartes ;
  · la note d'ordre entrelacé est à l'écran ET annonce ce que l'enregistrement fera
    (REGROUPERA) — l'annoncer après serait un constat, pas un avertissement ;
  · contrôle négatif : sur un ordre groupé et classé, aucune des deux n'apparaît. Sans
    lui, deux notes toujours affichées deviennent du décor ;
  · THING_MISSING se lit « l'appareil n'existe plus dans nymea » + « réinstallez-le, ou
    retirez cette charge », et n'offre RIEN à lever — ni le bouton, ni l'aide qui
    l'accompagne.

⚠️ La télémétrie du quatrième test est COMPOSÉE, pas capturée : le banc publiait
WRITE_FAILED sur cette charge au dernier relevé (l'adaptateur épuise son échelle
d'écriture avant que THING_MISSING ne sorte). Le test éprouve le rendu du code sur le
loadId réel de la Terra AC supprimée, pas la présence du code sur la box.

Et deux traces de la campagne V2C, qui ne se câblent pas :

· tools/rpc/evcharger_watch.dart entre au dépôt, documenté. C'est la sonde qui a
  produit les deux constats bloquants — une borne hors arbitrage n'apparaît pas dans
  telemetry_watch, et c'est pendant ces transitions qu'il faut la regarder.
· ev_charging_card.dart affirmait que « les deux bornes du banc publient sessionEnergy ».
  Faux depuis l'arrivée de la V2C : elle publie chargeEnergy, remis à zéro à chaque
  interruption de charge. La note renvoie désormais à LoadMechanism et redit C1 de
  LM-1009 — « pas mesurable », jamais zéro.

TODO : entrée d'attente pour sessionEnergy et le renommage currentL1/L2/L3.

146 tests passent, flutter analyze sans remarque nouvelle.

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

332 lines
14 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,
}) =>
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: 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);
});
});
// ───────────────────────────────────────────────────────────────────────────
// 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 COMPOSÉE ici, pas capturée : le banc publiait `WRITE_FAILED` sur
// cette charge au dernier relevé (l'adaptateur épuise son échelle d'écriture avant
// que `THING_MISSING` ne sorte). Ce test éprouve donc le RENDU du code, sur le
// `loadId` réel de la Terra AC supprimée — pas la présence du code sur la box.
const terra = '{88160e45-7df2-48cc-9639-f0d15ae6e382}';
final config = LoadConfigProvider()..adoptForTest(v2cConfig());
final telemetry = LoadTelemetryProvider()..adoptForTest({
'degradedMode': false,
'loads': [
{
'loadId': terra,
'allocatedW': 0,
'available': false,
'faultCode': 'THING_MISSING',
'decision': {'code': 'NO_BUDGET', 'params': const {}},
},
],
});
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);
});
});
}
extension on RpcSchema {
bool versionLineContains(String s) =>
(nymeaVersion ?? '').contains(s) ||
experienceVersions.values.any((v) => v.contains(s));
}