/// **Un brouillon revenu à son point de départ doit pouvoir se CONCLURE.** /// /// « Je n'ai rien changé » et « j'ai décidé de ne rien changer » sont deux états /// différents. Le second s'atteint par le seul geste de l'écran des charges qui n'a pas de /// repli — déplacer une plage — puis en le défaisant. /// /// L'écran traitait les deux pareil, et n'offrait plus pour en sortir que des portes mal /// nommées : la barre annonçait « Modifications en attente », la sortie d'écran menaçait /// d'abandonner des changements, l'adoption d'un rang et le retrait d'une charge /// réclamaient d'« enregistrer ou annuler ». Quatre messages, tous faux au même moment. /// /// ⚠️ Le remède N'EST PAS d'effacer le brouillon dès qu'il devient sans effet : la barre /// disparaîtrait sous le doigt à l'instant où l'utilisateur repose la plage, et /// « j'ai décidé » redeviendrait « il ne s'est rien passé ». library; 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/services/nymea_service.dart'; import 'package:etm_powersync_app/widgets/controllable_loads_section.dart'; import 'package:etm_powersync_app/widgets/garde_brouillon.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:provider/provider.dart'; Map _charge(String id, String label, String domain, int rang) => { 'id': id, 'label': label, 'domain': domain, 'priority': rang, 'adapter': 'relay-router', 'mode': 'fixed', 'enabled': true, }; /// Trois domaines distincts : trois plages, donc trois éléments déplaçables. List> _config() => [ _charge('a', 'V2C Trydan', 'ev', 1), _charge('b', 'chauffe-eau', 'ecs', 2), _charge('c', 'PAC banc', 'heating', 3), ]; LoadConfigProvider _provider() => LoadConfigProvider()..adoptForTest(_config()); void main() { group('l\'état « j\'ai décidé de ne rien changer »', () { test('sans brouillon, il n\'existe pas', () { final p = _provider(); expect(p.hasPendingChanges, isFalse); expect(p.draftIsNoOp, isFalse, reason: 'pas de brouillon n\'est pas un brouillon sans effet'); expect(p.hasSubstantiveChanges, isFalse); }); test('un déplacement réel change vraiment quelque chose', () { final p = _provider()..reorderDomains(2, 0); expect(p.hasPendingChanges, isTrue); expect(p.draftIsNoOp, isFalse); expect(p.hasSubstantiveChanges, isTrue); }); test('déplacer puis remettre laisse un brouillon SANS EFFET', () { final p = _provider() ..reorderDomains(2, 0) // C remonte en tête ..reorderDomains(0, 3); // et redescend en queue expect(p.hasPendingChanges, isTrue, reason: 'le brouillon SURVIT — il ne s\'évapore pas sous le doigt'); expect(p.draftIsNoOp, isTrue); expect(p.hasSubstantiveChanges, isFalse); // Et l'ordre affiché est bien celui du départ. expect([for (final g in p.groups) ...g.loads.map((e) => e.id)], ['a', 'b', 'c']); }); }); group('les portes de sortie disent la vérité', () { testWidgets('la barre ne propose plus d\'enregistrer, elle propose de TERMINER', (tester) async { final p = _provider() ..reorderDomains(2, 0) ..reorderDomains(0, 3); await tester.pumpWidget(MaterialApp( home: Scaffold(body: ImpactSaveBar(provider: p)), )); expect(find.textContaining('rien à enregistrer'), findsOneWidget); expect(find.text('Terminer'), findsOneWidget); // Les contrôles qui MORDENT : les deux portes mal nommées ont disparu. expect(find.text('Enregistrer'), findsNothing, reason: 'réécrire tout l\'ensemble pour n\'y rien changer ' 'reconstruirait les charges pour rien'); expect(find.textContaining('Modifications en attente'), findsNothing); await tester.tap(find.text('Terminer')); await tester.pump(); expect(p.hasPendingChanges, isFalse, reason: 'l\'état s\'est CONCLU'); }); testWidgets('un brouillon réel garde sa barre d\'enregistrement', (tester) async { final p = _provider()..reorderDomains(2, 0); await tester.pumpWidget(MaterialApp( home: Scaffold(body: ImpactSaveBar(provider: p)), )); expect(find.text('Enregistrer'), findsOneWidget); expect(find.text('Terminer'), findsNothing); }); testWidgets('la sortie d\'écran ne menace plus d\'une perte inexistante', (tester) async { final p = _provider() ..reorderDomains(2, 0) ..reorderDomains(0, 3); await tester.pumpWidget(ChangeNotifierProvider.value( value: p, child: MaterialApp( home: Scaffold( body: Builder( builder: (context) => ElevatedButton( onPressed: () => Navigator.of(context).push(MaterialPageRoute( builder: (_) => const GardeBrouillon( child: Scaffold(body: Text('écran des charges')), ), )), child: const Text('ouvrir'), ), ), ), ), )); await tester.tap(find.text('ouvrir')); await tester.pumpAndSettle(); expect(find.text('écran des charges'), findsOneWidget); // Retour arrière : aucune alerte, l'écran se ferme. final nav = tester.state(find.byType(Navigator)); nav.maybePop(); await tester.pumpAndSettle(); expect(find.text('Modifications non enregistrées'), findsNothing, reason: 'il n\'y avait rien à perdre'); expect(find.text('écran des charges'), findsNothing); expect(p.hasPendingChanges, isFalse, reason: 'le brouillon vide est ramassé, pas laissé traîner'); }); testWidgets('un brouillon RÉEL fait toujours barrage', (tester) async { final p = _provider()..reorderDomains(2, 0); await tester.pumpWidget(ChangeNotifierProvider.value( value: p, child: MaterialApp( home: Scaffold( body: Builder( builder: (context) => ElevatedButton( onPressed: () => Navigator.of(context).push(MaterialPageRoute( builder: (_) => const GardeBrouillon( child: Scaffold(body: Text('écran des charges')), ), )), child: const Text('ouvrir'), ), ), ), ), )); await tester.tap(find.text('ouvrir')); await tester.pumpAndSettle(); tester.state(find.byType(Navigator)).maybePop(); await tester.pumpAndSettle(); expect(find.text('Modifications non enregistrées'), findsOneWidget); expect(find.text('écran des charges'), findsOneWidget); }); }); testWidgets('la barre n\'est plus DANS la liste — elle est en pied d\'écran', (tester) async { // Posée à la suite des cartes, elle sortait du champ à l'instant où le geste la fait // apparaître : le correctif était juste et restait inatteignable. Une porte qu'on ne // voit pas est une porte fermée. // // Ce test garde le NÉGATIF — qu'elle ne revienne pas s'ancrer dans la liste. Le // positif (elle est bien en pied, visible sans défiler) se vérifie à l'appareil : // monter l'écran entier ici demanderait cinq providers et ne prouverait toujours rien // sur ce qui est à l'écran sans défilement. final config = LoadConfigProvider()..adoptForTest(_config()); config.reorderDomains(2, 0); expect(config.hasPendingChanges, isTrue); await tester.pumpWidget(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: SingleChildScrollView( child: ControllableLoadsSection(onConfigure: (_, __) {}), ), ), ), )); await tester.pump(); expect(find.byType(ImpactSaveBar), findsNothing, reason: 'la section ne doit plus porter la barre'); }); testWidgets('la barre tient dans un pied d\'écran — elle ne mange pas le corps', (tester) async { // Le contrôle qui MORD, et qu'aucun des 319 tests précédents ne pouvait faire : // tous montaient la barre dans une hauteur INFINIE (un `ListView`), où // `MainAxisSize.max` — le défaut d'un `Column` — se comporte comme `min`. // // En `bottomNavigationBar` la contrainte est lâche mais BORNÉE par l'écran, et le // Column a pris toute la hauteur : corps écrasé à zéro, écran vide sous la barre. // Trouvé à l'appareil, en release, pas ici. // // Un widget qui ne se contraint pas lui-même hérite du contexte : le déplacer change // ses contraintes, donc sa taille, sans qu'une ligne de son code ait bougé. final p = _provider()..reorderDomains(2, 0); await tester.pumpWidget(MaterialApp( home: Scaffold( bottomNavigationBar: ImpactSaveBar(provider: p), body: const Center(child: Text('le corps')), ), )); await tester.pump(); final h = tester.getSize(find.byType(ImpactSaveBar)).height; final ecran = tester.getSize(find.byType(Scaffold)).height; expect(h, lessThan(ecran / 3), reason: 'la barre doit se contenter de ses enfants, pas prendre l\'écran ' '(mesurée : $h sur $ecran)'); expect(find.text('le corps'), findsOneWidget); }); }