/// 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 les contacteurs déclenche un avertissement, pas un silence', () { 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, isTrue, reason: 'la comparaison du moteur est indexée — vérifié sur .75'); 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); }); }); } extension on RpcSchema { bool versionLineContains(String s) => (nymeaVersion ?? '').contains(s) || experienceVersions.values.any((v) => v.contains(s)); }