/// L'IU de configuration, **sur l'appareil, contre la vraie box**. /// /// ## Pourquoi ce test existe sous cette forme /// /// `adb shell input tap` est refusé sur le Redmi Note 9S : /// `SecurityException: Injecting to another application requires INJECT_EVENTS /// permission` — verrou MIUI qui demande un compte Xiaomi pour se lever. Un test /// `integration_test` ne passe pas par là : il s'exécute **dans le processus de l'app** et /// tape sur les widgets depuis l'intérieur. C'est donc bien l'application réelle, sur le /// téléphone réel, parlant à `.75` par le réseau. /// /// ## Ce qu'il exerce, et ce qu'il rend au banc /// /// Il se connecte, ouvre l'écran fusionné, lit les charges **réelles**, puis écrit deux /// fois : un renommage (mise à jour en place — aucun verrou réarmé) et sa restauration. /// Rien de matériel n'est touché : le test ne réordonne aucun contacteur et ne désactive /// aucune charge, parce que ces gestes ouvrent des contacts et réarment des verrous sur /// une installation qui n'est pas la sienne. /// /// Lancement : /// ```sh /// flutter test integration_test/config_on_device_test.dart -d dee659e2 /// ``` library; import 'package:etm_powersync_app/main.dart' as app; import 'package:etm_powersync_app/providers/installer_mode_provider.dart'; import 'package:etm_powersync_app/providers/load_config_provider.dart'; import 'package:etm_powersync_app/providers/schema_provider.dart'; import 'package:etm_powersync_app/screens/energy/roles_devices_screen.dart'; import 'package:etm_powersync_app/services/connection_manager.dart'; import 'package:etm_powersync_app/services/nymea_service.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; import 'package:integration_test/integration_test.dart'; import 'package:provider/provider.dart'; /// La box de banc. `nymea-dev-rpi` doit être déjà enregistrée sur l'appareil. const _hote = '192.168.1.75'; /// PIN installateur par défaut, posé au premier démarrage tant qu'aucun n'a été choisi. const _pinInstallateur = '1234'; /// Pompe jusqu'à ce que [cond] soit vraie, ou échoue en le disant. /// /// `pumpAndSettle` ne convient pas ici : l'app garde des animations et un flux de /// notifications vivants tant qu'elle est connectée, si bien qu'elle ne « se pose » /// jamais. On pompe donc par pas fixes. Future _attendre( WidgetTester tester, bool Function() cond, { Duration limite = const Duration(seconds: 30), String quoi = 'condition', }) async { final fin = DateTime.now().add(limite); while (DateTime.now().isBefore(fin)) { await tester.pump(const Duration(milliseconds: 250)); if (cond()) return; } fail('délai dépassé en attendant : $quoi'); } void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); testWidgets('configuration des charges, sur appareil, contre .75', (tester) async { app.main(); await tester.pump(const Duration(seconds: 3)); // ── 1. Connexion à la box de banc ──────────────────────────────────────── // // Le test ENREGISTRE lui-même la box au lieu de compter sur une installation déjà // stockée. Ce n'est pas du confort : `flutter test integration_test` **réinstalle** // l'app, ce qui vide `shared_preferences` et le stockage sécurisé — donc efface les // installations. Un test qui suppose l'état du téléphone échouerait dès le premier // run et, pire, laisserait l'appareil sans aucune box enregistrée. // // `connectNew()` se connecte ET persiste : la box est donc rendue au téléphone. final ctx = tester.element(find.byType(MaterialApp).first); final svc = Provider.of(ctx, listen: false); final cnx = Provider.of(ctx, listen: false); if (!svc.isConnected) { // ignore: unawaited_futures cnx.connectNew(host: _hote); } await _attendre(tester, () => svc.isConnected, limite: const Duration(seconds: 60), quoi: 'la connexion à $_hote'); // ── 2. Le schéma est lu à la connexion, pas à l'ouverture d'un écran ───── final schema = Provider.of(ctx, listen: false); await _attendre(tester, () => schema.schema.loaded, limite: const Duration(seconds: 30), quoi: 'la lecture du schéma'); expect(schema.schema.hasMethod('NymeaEnergy.SetLoadConfig'), isTrue); expect(schema.schema.experienceVersions['NymeaEnergy'], isNotNull); debugPrint('[appareil] schéma lu — ${schema.versionLine()}'); // ── 3. L'écran fusionné, et les charges RÉELLES de la box ──────────────── // // Le contexte du MaterialApp est AU-DESSUS du Router : `GoRouter.of` n'y trouve // rien. On prend donc un contexte pris sous lui — celui d'un Scaffold affiché. // `/energy/setup` est une route RÉSERVÉE AU MODE INSTALLATEUR : le routeur la renvoie // vers `/` tant qu'il est verrouillé (`_isInstallerRoute`). Ce n'est pas un défaut — // c'est le même verrou qui masque l'ajout d'installation. Sans déverrouillage, la // navigation « réussit » sans rien monter, et le symptôme observé est « aucune // charge » : un diagnostic parfaitement trompeur, d'où le contrôle de montage plus bas. final installateur = Provider.of(ctx, listen: false); if (!installateur.isUnlocked) { await installateur.unlock(_pinInstallateur); await tester.pump(const Duration(milliseconds: 500)); } expect(installateur.isUnlocked, isTrue, reason: 'le PIN installateur par défaut doit ouvrir les écrans de configuration'); debugPrint('[appareil] mode installateur déverrouillé'); final ctxSousRouteur = tester.element(find.byType(Scaffold).first); GoRouter.of(ctxSousRouteur).go('/energy/setup'); await tester.pump(const Duration(seconds: 3)); // L'écran DOIT être monté : c'est son initState qui branche LoadConfigProvider sur le // service et déclenche la lecture. Si la navigation n'a pas eu lieu, le symptôme // serait « aucune charge » — un diagnostic trompeur. On vérifie donc d'abord. await _attendre( tester, () => find.byType(RolesDevicesScreen).evaluate().isNotEmpty, quoi: 'le montage de l\'écran « Rôles & appareils »', ); debugPrint('[appareil] écran de configuration monté'); final config = Provider.of(ctx, listen: false); await _attendre(tester, () => config.entries.isNotEmpty, limite: const Duration(seconds: 40), quoi: 'les charges de GetLoadConfig ' '(erreur du provider : ${config.error ?? "aucune"})'); final ids = config.entries.map((e) => e.id).toList()..sort(); debugPrint('[appareil] charges lues : $ids'); expect(ids, contains('chauffe-eau')); expect(ids, contains('pac-terrain')); // Les deux charges s'affichent, avec un bouton de détail chacune. await _attendre( tester, () => find.text('Configurer').evaluate().length >= 2, quoi: 'les cartes de charge à l\'écran', ); expect(find.text('chauffe-eau'), findsWidgets); // La PAC vient du lot B-bis : avant lui, elle était arbitrée sans être // configurable. Sa présence ICI est la preuve visible du contrat. expect(find.text('PAC banc'), findsWidgets); // ── 4. Une écriture RÉELLE, non matérielle, et sa confirmation ─────────── final avant = config.entries.firstWhere((e) => e.id == 'chauffe-eau'); const nouveau = 'chauffe-eau (sonde appareil)'; config.setLabel('chauffe-eau', nouveau); await tester.pump(const Duration(milliseconds: 400)); expect(config.hasPendingChanges, isTrue); // Un renommage ne touche à aucun matériel : rien à annoncer. expect(config.pendingNeedsWarning, isFalse); await config.save(); // La confirmation est `LoadConfigChanged`, PAS l'accusé du RPC — mesuré sur le banc, // la notification arrive la première. await _attendre( tester, () => config.saveState == LoadSaveState.confirmed || config.saveState == LoadSaveState.diverged || config.saveState == LoadSaveState.rejected, limite: const Duration(seconds: 20), quoi: 'la confirmation par LoadConfigChanged', ); debugPrint('[appareil] écriture → ${config.saveState}'); expect(config.saveState, LoadSaveState.confirmed); expect( config.entries.firstWhere((e) => e.id == 'chauffe-eau').label, nouveau, ); // ── 5. Restauration — le banc n'appartient pas au test ────────────────── config.setLabel('chauffe-eau', avant.label); await tester.pump(const Duration(milliseconds: 400)); await config.save(); await _attendre( tester, () => config.saveState == LoadSaveState.confirmed && config.entries.firstWhere((e) => e.id == 'chauffe-eau').label == avant.label, limite: const Duration(seconds: 20), quoi: 'la restauration du libellé', ); debugPrint('[appareil] libellé restauré : ${avant.label}'); // ── 6. On laisse l'écran affiché ──────────────────────────────────────── // // `flutter test integration_test` referme l'app dès la dernière assertion. Sans // cette pause, il n'y a matériellement rien à voir sur le téléphone : d'où // l'impression que « rien ne tourne » alors que tout s'est exécuté. On tient donc // l'écran ouvert le temps de le regarder et d'en prendre une capture. // ORDRE IMPORTANT : on tient l'écran D'ABORD, on reverrouille APRÈS. // // Reverrouiller avant la pause renvoie immédiatement vers `/` — `/energy/setup` est // une route installateur et le `redirect` du routeur s'applique en continu. La // première version faisait l'inverse : le test passait, mais la capture montrait le // tableau de bord. Le verrou marchait trop bien. debugPrint('[appareil] écran maintenu 90 s — regardez le téléphone'); for (var i = 0; i < 360; i++) { await tester.pump(const Duration(milliseconds: 250)); } // Le mode installateur ne reste pas ouvert derrière le test. installateur.lock(); await tester.pump(const Duration(milliseconds: 500)); debugPrint('[appareil] mode installateur reverrouillé'); }); }