/// Le réordonnancement du rang — **où doit vivre la prise ?** /// /// Constaté sur appareil : déplacer une charge ne produisait rien. L'enquête a donné trois /// résultats, et le dernier a fait revenir sur un choix de conception. /// /// 1. **Le geste n'est pas avalé** par le `ListView` parent : section seule et section /// imbriquée se comportent identiquement. /// 2. **Le symptôme venait du banc** : `.75` n'a qu'une charge par domaine, et glisser dans /// une liste d'un élément ne peut rien produire. /// 3. **Le niveau glissable était le mauvais.** Sur une installation ordinaire, chaque /// domaine porte une charge : l'ordre de service se règle donc *entre domaines*, et /// c'est là que la prise doit être. Rendre les deux niveaux glissables est impossible — /// deux `ReorderableListView` imbriqués se disputent le geste, et l'extérieur gagne. /// Le glissement appartient donc au **domaine** ; à l'intérieur, deux charges se /// réordonnent par des **flèches**. /// /// Un défaut réel a été trouvé au passage et corrigé : le centre de la poignée tombait dans /// le vide séparant l'icône du reste, et `ReorderableDragStartListener` défère le test de /// collision à ses enfants — bande morte invisible. D'où le `Container` transparent, et /// d'où le test qui saisit délibérément le **centre du bloc**. library; 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_priority.dart'; import 'package:etm_powersync_app/services/nymea_service.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'; /// Deux charges **dans le MÊME domaine** — la seule configuration où un glissement a un /// sens. C'est précisément ce que le banc n'a pas. List> _deuxDansUnDomaine() => [ { 'adapter': 'relay-router', 'domain': 'ecs', 'enabled': true, 'id': 'chauffe-eau', 'label': 'chauffe-eau', 'maxPowerW': 0, 'minOffS': 60, 'minOnS': 60, 'mode': 'fixed', 'needs': {'dailyDeadline': '', 'minEnergyWhPerDay': 0}, 'powerLevels': [], 'priority': 1, 'relays': [ {'powerW': 2000, 'thingId': '{b033b212-1adb-4df0-ba2b-8fa477de52a2}'} ], 'sgReady': {}, }, { 'adapter': 'relay-router', 'domain': 'ecs', 'enabled': true, 'id': 'seche-serviettes', 'label': 'Sèche-serviettes', 'maxPowerW': 0, 'minOffS': 60, 'minOnS': 60, 'mode': 'fixed', 'needs': {'dailyDeadline': '', 'minEnergyWhPerDay': 0}, 'powerLevels': [], 'priority': 2, 'relays': [ {'powerW': 500, 'thingId': '{8538782f-2c8c-4a30-bfce-c8140f791c9b}'} ], 'sgReady': {}, }, ]; Widget _montage({ required LoadConfigProvider config, required bool dansUnListView, }) { final section = ControllableLoadsSection(onConfigure: (_, __) {}); return MultiProvider( providers: [ ChangeNotifierProvider.value(value: config), ChangeNotifierProvider.value(value: LoadTelemetryProvider()), ChangeNotifierProvider.value(value: NymeaService()), ], child: MaterialApp( locale: const Locale('fr'), localizationsDelegates: L10n.localizationsDelegates, supportedLocales: L10n.supportedLocales, home: Scaffold( body: dansUnListView // Reproduit l'écran réel : la section est un enfant du ListView de // « Rôles & appareils ». ? ListView(children: [section]) : SingleChildScrollView(child: section), ), ), ); } /// Ordre des charges du premier domaine, tel que la vue le porte. List _ordre(LoadConfigProvider p) => [for (final g in p.groups) ...g.loads.map((e) => e.id)]; /// Glisse une poignée désignée par sa clé, par-dessus la carte [parDessus]. /// /// La distance se CALCULE depuis la hauteur réelle de la carte visée : une valeur en dur /// devient fausse dès qu'on ajoute une ligne à la carte, et le test échoue alors pour une /// raison qui n'a rien à voir avec le geste. C'est exactement ce qui est arrivé. Future _glisserCle( WidgetTester tester, String cle, String parDessus) async { final poignee = find.byKey(ValueKey(cle)); expect(poignee, findsOneWidget, reason: 'poignée « $cle » introuvable'); final cible = find.byKey(ValueKey(parDessus)); expect(cible, findsOneWidget, reason: 'cible « $parDessus » introuvable'); final dy = tester.getSize(cible).height * 2; final g = await tester.startGesture(tester.getCenter(poignee)); // ReorderableDragStartListener accepte le geste immédiatement : pas d'appui long. await tester.pump(const Duration(milliseconds: 40)); for (var i = 0; i < 12; i++) { await g.moveBy(Offset(0, dy / 12)); await tester.pump(const Duration(milliseconds: 16)); } await g.up(); await tester.pumpAndSettle(); } /// La configuration RÉELLE du banc : une charge par domaine. C'est là que le glissement /// d'une carte ne peut rien produire — et que celui des domaines doit prendre le relais. List> _uneParDomaine() { final l = _deuxDansUnDomaine(); l[1] = {...l[1], 'domain': 'hvac', 'id': 'pac-terrain', 'label': 'PAC banc'}; return l; } void main() { testWidgets('glisser un DOMAINE réordonne le service — section seule', (tester) async { final config = LoadConfigProvider()..adoptForTest(_uneParDomaine()); expect(_ordre(config), ['chauffe-eau', 'pac-terrain']); await tester.pumpWidget(_montage(config: config, dansUnListView: false)); await tester.pumpAndSettle(); await _glisserCle(tester, 'poignee-domaine-ecs', 'load-pac-terrain'); expect(_ordre(config), ['pac-terrain', 'chauffe-eau'], reason: 'le glissement du domaine doit produire un nouvel ordre'); expect(config.hasPendingChanges, isTrue); }); testWidgets('le geste survit à l\'imbrication dans le ListView de l\'écran', (tester) async { final config = LoadConfigProvider()..adoptForTest(_uneParDomaine()); await tester.pumpWidget(_montage(config: config, dansUnListView: true)); await tester.pumpAndSettle(); await _glisserCle(tester, 'poignee-domaine-ecs', 'load-pac-terrain'); expect(_ordre(config), ['pac-terrain', 'chauffe-eau'], reason: 'si CE test échoue seul, le ListView parent avale le geste'); }); testWidgets('deux charges dans un domaine — les FLÈCHES les réordonnent', (tester) async { final config = LoadConfigProvider()..adoptForTest(_deuxDansUnDomaine()); expect(_ordre(config), ['chauffe-eau', 'seche-serviettes']); await tester.pumpWidget(_montage(config: config, dansUnListView: true)); await tester.pumpAndSettle(); // Descendre la première : c'est le geste interne au domaine. final bas = find.byKey(const ValueKey('descendre-chauffe-eau')); expect(bas, findsOneWidget); await tester.tap(bas); await tester.pumpAndSettle(); expect(_ordre(config), ['seche-serviettes', 'chauffe-eau']); }); testWidgets('une charge seule dans son domaine n\'a pas de flèches', (tester) async { // Rien à réordonner à l'intérieur : ne pas offrir de commande qui ne mène nulle part. final config = LoadConfigProvider()..adoptForTest(_uneParDomaine()); await tester.pumpWidget(_montage(config: config, dansUnListView: true)); await tester.pumpAndSettle(); expect(find.byKey(const ValueKey('descendre-chauffe-eau')), findsNothing); expect(find.byKey(const ValueKey('monter-pac-terrain')), findsNothing); // Mais le domaine, lui, se glisse. expect(find.byKey(const ValueKey('poignee-domaine-ecs')), findsOneWidget); }); test('le rang envoyé à la box se renumérote depuis la POSITION', () { final config = LoadConfigProvider()..adoptForTest(_deuxDansUnDomaine()); config.reorderWithinDomain('ecs', 0, 2); // 1re → après la 2e final payload = flattenToPayload(config.groups); expect([for (final m in payload) m['id']], ['seche-serviettes', 'chauffe-eau']); // C'est bien la POSITION qui fait le rang, pas la valeur lue sur la box. expect([for (final m in payload) m['priority']], [1, 2]); }); }