/// L'écran « Configurer » d'une BORNE — deux défauts trouvés sur téléphone, pas en test. /// /// Le 2026-08-28, app installée en release contre `.75` : /// /// 1. l'écran **refusait d'enregistrer** une borne — « La puissance nominale est requise et /// doit être supérieure à 0 W » — alors que sa propre carte « Mécanisme » explique deux /// blocs plus haut qu'une borne n'en porte pas. `_valider()` faisait tomber /// `adapter: evcharger` dans la branche des charges modulables ; /// 2. il proposait une **sonde de température** sur une borne de recharge. Le champ est /// générique au schéma (`sensorThingId` s'écrit sur n'importe quelle charge), mais le /// moteur ne le lit que pour publier une température — sur une borne, il ne mène nulle /// part. /// /// Aucun des 164 tests ne pouvait les voir : le premier vit dans une méthode privée de /// widget, le second est une question de sens, pas de valeur. library; import 'dart:convert'; import 'dart:io'; import 'package:etm_powersync_app/l10n/app_localizations.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/providers/schema_provider.dart'; import 'package:etm_powersync_app/screens/energy/load_mechanism_screen.dart'; import 'package:etm_powersync_app/models/nymea_models.dart'; import 'package:etm_powersync_app/services/nymea_service.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:provider/provider.dart'; /// Configuration réelle du banc : chauffe-eau (relais), PAC (SG-Ready), trois bornes. List> _banc() { final raw = jsonDecode( File('test/fixtures/loadconfig_hems75_v2c.json').readAsStringSync()); return ((raw as Map)['loadConfigs'] as List) .cast>(); } const kV2c = '{4d1adafd-cafc-4813-93b1-ca8933ce71ab}'; /// La V2C telle que `.75` la publie : classe `trydan` portant l'interface `evcharger`, et /// les trois états que la tuile et l'écran lisent. NymeaService _serviceAvecBorne({String mode = 'ChargingModeNormal'}) { const classId = 'cls-trydan'; final svc = NymeaService() ..adoptThingsForTest( [ NymeaThing( id: '4d1adafd-cafc-4813-93b1-ca8933ce71ab', name: 'V2C Trydan', thingClassId: classId, setupStatus: 'ThingSetupStatusComplete', paramValues: const [], states: const [ NymeaStateValue(stateTypeId: 'st-plugged', value: false), NymeaStateValue(stateTypeId: 'st-charging', value: false), NymeaStateValue(stateTypeId: 'st-power', value: 0), ], ), ], [ const NymeaThingClass( id: classId, name: 'trydan', displayName: 'Trydan', interfaces: ['evcharger'], stateTypes: [ NymeaStateType( id: 'st-plugged', name: 'pluggedIn', displayName: 'Branché', type: 'Bool', unit: ''), NymeaStateType( id: 'st-charging', name: 'charging', displayName: 'En charge', type: 'Bool', unit: ''), NymeaStateType( id: 'st-power', name: 'currentPower', displayName: 'Puissance', type: 'Double', unit: 'W'), ], ), ], ); svc.adoptChargingInfosForTest([ { 'evChargerId': '4d1adafd-cafc-4813-93b1-ca8933ce71ab', 'chargingMode': mode, 'chargingState': 'ChargingStateIdle', }, ]); return svc; } Widget _harness(String loadId, {List>? config, NymeaService? service, Map? telemetry}) => MultiProvider( providers: [ ChangeNotifierProvider.value( value: LoadConfigProvider()..adoptForTest(config ?? _banc())), ChangeNotifierProvider.value( value: LoadTelemetryProvider() ..adoptForTest( telemetry ?? {'degradedMode': false, 'loads': const []})), ChangeNotifierProvider.value(value: SchemaProvider()), ChangeNotifierProvider.value(value: service ?? NymeaService()), ], // Les délégués de localisation sont indispensables : `_Identite` lit `context.l10n` // dès sa construction, et sans eux l'écran explose avant d'afficher quoi que ce soit. child: MaterialApp( locale: const Locale('fr'), localizationsDelegates: L10n.localizationsDelegates, supportedLocales: L10n.supportedLocales, home: LoadMechanismScreen(loadId: loadId), ), ); /// Grande surface : l'écran est un `ListView`, qui ne construit QUE ce qui entre dans son /// viewport. Sur 600 px de haut, le compteur, la sonde et la barre d'enregistrement ne /// sont jamais bâtis — et `find` ne trouve rien, ce qui se lit comme un succès quand on /// cherche une absence. C'est le piège exact que ce fichier éprouve ailleurs. Future _poser(WidgetTester tester, Widget w) async { // 8000 px : les écrans relais et SG-Ready sont bien plus hauts que celui d'une borne, // et le compteur y tombait hors du viewport — donc jamais construit, donc « absent » // pour `find`. Une absence qui n'en est pas une : le piège que ce fichier traque. await tester.binding.setSurfaceSize(const Size(1000, 8000)); addTearDown(() => tester.binding.setSurfaceSize(null)); await tester.pumpWidget(w); await tester.pump(); } void main() { testWidgets('une BORNE ne réclame aucune puissance nominale', (tester) async { await _poser(tester, _harness(kV2c)); // Le blocage qui empêchait d'enregistrer un simple renommage. expect(find.textContaining('puissance nominale est requise'), findsNothing); // Et l'écran dit toujours POURQUOI elle n'en a pas. expect(find.textContaining('ni contacts, ni paliers'), findsOneWidget); }); testWidgets('une charge MODULABLE, elle, la réclame toujours', (tester) async { // Contrôle négatif : la règle n'est pas supprimée, elle est rendue à son périmètre. final cfg = [ for (final c in _banc()) if (c['id'] == 'chauffe-eau') { ...c, 'adapter': 'etmvariableload', 'relays': const [], 'maxPowerW': 0, } else c, ]; await _poser(tester, _harness('chauffe-eau', config: cfg)); expect(find.textContaining('puissance nominale est requise'), findsOneWidget); }); testWidgets('pas de sonde de température sur une borne — le compteur reste', (tester) async { await _poser(tester, _harness(kV2c)); expect(find.text('Sonde de température'), findsNothing); // Le compteur, lui, garde un sens : c'est le seul chemin par lequel une mesure // apparaît, et une borne peut se désigner elle-même (elle publie `currentPower`). expect(find.text('Compteur dédié'), findsOneWidget); // Hors télémétrie, la box ne dit pas d'où viendrait la mesure : l'écran ne le // devine pas non plus. expect(find.textContaining('pas dans la télémétrie du dernier cycle'), findsOneWidget); }); testWidgets('sur une charge thermique, la sonde est bien là', (tester) async { await _poser(tester, _harness('chauffe-eau')); expect(find.text('Sonde de température'), findsOneWidget); }); testWidgets('l\'écran n\'envoie plus vers une carte véhicule supprimée', (tester) async { // Elle renvoyait au « tableau de bord », où la carte a été supprimée le 2026-08-28 : // l'installateur y aurait cherché un réglage qui n'y est pas. await _poser(tester, _harness(kV2c)); expect(find.textContaining('tableau de bord'), findsNothing); expect(find.textContaining('tuile « Borne EV » des favoris'), findsOneWidget); }); testWidgets('les trois modes de recharge sont SUR CET ÉCRAN', (tester) async { // Ils vivaient aux favoris ; « Configurer » est l'écran de mise en service. await _poser(tester, _harness(kV2c, service: _serviceAvecBorne())); expect(find.widgetWithText(OutlinedButton, 'PV'), findsOneWidget); expect(find.widgetWithText(OutlinedButton, 'Min+PV'), findsOneWidget); expect(find.widgetWithText(OutlinedButton, 'Boost'), findsOneWidget); // Le mode LU est affiché à côté, et c'est celui de la box. expect(find.textContaining('lu : Boost'), findsOneWidget); // Et l'avertissement d'une borne neuve, là où on la met en service. expect(find.textContaining('hors arbitrage'), findsOneWidget); }); testWidgets('sans ChargingInfo lue, aucun bouton — on ne devine pas un mode', (tester) async { // Service sans borne connue : l'écran doit le DIRE, pas allumer un bouton au hasard. await _poser(tester, _harness(kV2c)); expect(find.widgetWithText(OutlinedButton, 'PV'), findsNothing); expect(find.textContaining('Mode de recharge non lu'), findsOneWidget); }); testWidgets('un mode éco fait taire l\'avertissement, les boutons restent', (tester) async { await _poser( tester, _harness(kV2c, service: _serviceAvecBorne(mode: 'ChargingModeEco'))); expect(find.textContaining('hors arbitrage'), findsNothing); expect(find.widgetWithText(OutlinedButton, 'PV'), findsOneWidget); expect(find.textContaining('lu : PV'), findsOneWidget); }); group('le compteur dédié se décide sur la SOURCE, jamais sur l\'adaptateur', () { /// Une trame où la charge visée publie la source donnée. Map tel(String id, String source) => { 'degradedMode': false, 'loads': [ { 'loadId': id, 'allocatedW': 3000, 'available': true, 'decision': {'code': 'SURPLUS_SETPOINT', 'params': const {}}, 'measurement': source == 'none' ? {'source': 'none'} : {'source': source, 'powerW': 2950.0}, } ], }; testWidgets('source `device` : le compteur passe en réglage avancé, replié', (tester) async { await _poser(tester, _harness(kV2c, telemetry: tel(kV2c, 'device'))); // La carte reste — replier n'est pas masquer, et un réglage caché pour de bon // finit par être réinventé ailleurs. expect(find.text('Compteur dédié'), findsOneWidget); expect(find.textContaining('publie déjà sa puissance'), findsOneWidget); // Mais son contenu n'est pas déployé : ni le sélecteur, ni les aides. expect(find.textContaining('n\'entre pas dans le budget'), findsNothing); }); testWidgets('source `none` : le compteur est PROPOSÉ, visible', (tester) async { // Le cas d'une PAC SG-Ready — `none` par construction (LM-1105-b) — et celui d'une // topologie de relais mixte. Masquer le champ y couperait la seule voie de mesure. await _poser( tester, _harness('pac-terrain', telemetry: tel('pac-terrain', 'none'))); expect(find.text('Compteur dédié'), findsOneWidget); // Carte DÉPLOYÉE : son contenu est monté, pas seulement son titre. expect(find.textContaining('n\'entre pas dans le budget'), findsOneWidget); expect(find.textContaining('seule voie vers une mesure'), findsOneWidget); }); testWidgets('source `meter` : visible aussi, et l\'écran dit ce qu\'elle prouve', (tester) async { await _poser(tester, _harness('chauffe-eau', telemetry: tel('chauffe-eau', 'meter'))); expect(find.textContaining('n\'entre pas dans le budget'), findsOneWidget); expect(find.textContaining('constitue une preuve'), findsOneWidget); }); }); }