/// 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 _fixture(String name) => jsonDecode(File('test/fixtures/$name').readAsStringSync()) as Map; /// 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> _benchConfig() { final raw = (_fixture('loadconfig_hems75_v3.json')['loadConfigs'] as List) .cast>(); 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); 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> v2cConfig() => (_fixture('loadconfig_hems75_v2c.json')['loadConfigs'] as List) .cast>(); 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(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(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); 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)); }