/// 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/energy_setup_provider.dart'; 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/load_telemetry_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:etm_powersync_app/services/rpc_schema.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. // // Il faut DESCENDRE pour les atteindre : dans un `ListView`, ce qui n'est pas à // l'écran n'est pas construit, et l'en-tête d'arbitrage puis la carte de réserve // batterie occupent le haut. Chercher sans faire défiler ne prouverait que la hauteur // de l'écran. for (var pas = 0; pas < 20; pas++) { if (find.text('Configurer').evaluate().length >= 2) break; await tester.drag(find.byType(Scrollable).first, const Offset(0, -200)); await tester.pump(const Duration(milliseconds: 200)); } expect(find.text('Configurer'), findsWidgets, reason: 'les cartes de charge doivent être atteignables'); 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}'); // ── 5-zero. La réserve batterie est LUE et affichée ───────────────────── // // Un seuil qui peut geler toute l'installation ne doit pas rester un paramètre // d'usine invisible : le symptôme, chez un client, est un HEMS muet par grand soleil, // sans erreur ni panne. final reserve = await svc.getBatteryLevelConsideration(); debugPrint('[appareil] réserve batterie : $reserve ' '(SOC ${svc.energyData.batterySOC.round()} %)'); expect(reserve, isNotNull, reason: '.75 expose le réglage'); await _attendre( tester, () => find.text('Réserve batterie').evaluate().isNotEmpty, quoi: 'la carte de réserve batterie à l\'écran', ); // ── 5-bis. LE GLISSEMENT, sur l'appareil, contre la vraie box ─────────── // // Le test POSE lui-même sa condition au lieu de la subir. Le glissement n'a de sens // que si deux charges partagent un domaine ; le banc, lui, revient régulièrement à une // charge par domaine — deux fois de suite le test s'est donc contenté d'annoncer // « non exercé ». Un test qui dépend de l'état du terrain ne teste rien de façon // fiable : il déclare forfait au moment où on aurait besoin de lui. // // Il regroupe donc, exerce, puis REND le classement d'origine. final domainesOrigine = { for (final e in config.entries) e.id: e.domain ?? '' }; debugPrint('[appareil] classement d\'origine : $domainesOrigine'); Future ecrireDomaines(Map cible) async { for (final entry in cible.entries) { config.assignDomain(entry.key, entry.value); } await tester.pump(const Duration(milliseconds: 300)); if (!config.hasPendingChanges) return; await config.save(); await _attendre( tester, () => config.saveState == LoadSaveState.confirmed || config.saveState == LoadSaveState.diverged || config.saveState == LoadSaveState.rejected, limite: const Duration(seconds: 20), quoi: 'la confirmation du classement', ); expect(config.saveState, LoadSaveState.confirmed); } // Regrouper les deux charges dans un même domaine. const domaineTest = 'ecs'; await ecrireDomaines({for (final id in domainesOrigine.keys) id: domaineTest}); await tester.pump(const Duration(seconds: 1)); final parDomaine = {}; for (final e in config.entries) { parDomaine.update(e.domain ?? '', (v) => v + 1, ifAbsent: () => 1); } debugPrint('[appareil] regroupées : $parDomaine'); expect(parDomaine[domaineTest], greaterThan(1), reason: 'le préambule doit avoir regroupé les charges'); // Exercer le glissement — le domaine porte maintenant plusieurs charges, donc ce sont // les FLÈCHES de carte qui ordonnent à l'intérieur (le glisser-déposer, lui, déplace // les domaines entre eux). final ordreAvant = [for (final g in config.groups) ...g.loads.map((e) => e.id)]; final premier = ordreAvant.first; final bas = find.byKey(ValueKey('descendre-$premier')); await tester.ensureVisible(bas); await tester.pump(const Duration(milliseconds: 300)); await tester.tap(bas); await tester.pump(const Duration(milliseconds: 400)); final ordreApres = [for (final g in config.groups) ...g.loads.map((e) => e.id)]; debugPrint('[appareil] réordonnancement : $ordreAvant → $ordreApres'); expect(ordreApres.first, isNot(premier), reason: 'descendre la première charge doit changer l\'ordre'); // Le rang du banc n'appartient pas au test. config.discardDraft(); await tester.pump(const Duration(milliseconds: 300)); // RENDRE le classement d'origine. await ecrireDomaines(domainesOrigine); await tester.pump(const Duration(seconds: 1)); debugPrint('[appareil] classement rétabli : ' '${{for (final e in config.entries) e.id: e.domain ?? ''}}'); // ── 5-bis-bis. Le compteur racine est RELU, pas redemandé ─────────────── // // Il vivait sur la box sans que l'app le lise jamais : le rôle repartait « à // configurer » à chaque démarrage. Une configuration jamais lue se présente comme une // configuration perdue — et pousse à refaire un geste inutile. final racine = await svc.getRootMeter(); debugPrint('[appareil] compteur racine lu sur la box : ${racine ?? 'AUCUN'}'); expect(racine, isNotNull, reason: '.75 déclare un compteur racine : l\'app doit pouvoir le lire'); final setup = Provider.of(ctx, listen: false); await setup.syncRootMeter(svc); await tester.pump(const Duration(milliseconds: 300)); expect(setup.toConfigureRoles.map((r) => r.name), isNot(contains('gridMeter')), reason: 'le compteur racine ne doit plus être réclamé une fois lu'); // Et les CHARGES n'ont plus rien à faire dans ce décompte : elles viennent de // GetLoadConfig depuis la fusion. for (final charge in ['dhw', 'heatPump', 'battery', 'evCharger']) { expect(setup.toConfigureRoles.map((r) => r.name), isNot(contains(charge)), reason: '« $charge » est une charge : elle ne se configure plus par un rôle'); } debugPrint('[appareil] « à configurer » : ' '${setup.toConfigureRoles.map((r) => r.name).toList()}'); // ── 5-septies. Les bornes de recharge, toutes, et sans rang ───────────── // // Elles ne sont pas des LoadConfig : elles n'ont ni adapter, ni priority, ni domain, // et n'apparaissent dans aucun domaine. Sans leur section, une borne configurée serait // simplement invisible. La liste vient de l'interface nymea `evcharger`, donc toute // borne appairée y entre — y compris une seconde. final classesBorne = { for (final c in svc.thingClasses) if (c.interfaces.contains('evcharger')) c.id }; final bornes = [ for (final t in svc.things) if (classesBorne.contains(t.thingClassId)) t.name ]; debugPrint('[appareil] bornes vues : $bornes'); expect(bornes, isNotEmpty, reason: '.75 en déclare au moins une'); // ── 5-ter. Le chip de domaine PORTE son libellé ───────────────────────── // // Il ne rendait qu'une étiquette dessinée : le domaine d'une charge était illisible // sur sa carte, alors que c'est lui qui décide de son groupe et de son rang. final chip = find.byKey(const ValueKey('chip-domaine-chauffe-eau')); await _attendre(tester, () => chip.evaluate().isNotEmpty, quoi: 'le chip de domaine de chauffe-eau'); final texteChip = find .descendant(of: chip, matching: find.byType(Text)) .evaluate() .map((e) => (e.widget as Text).data) .whereType() .toList(); debugPrint('[appareil] chip de domaine : $texteChip'); expect(texteChip.any((t) => t.trim().isNotEmpty), isTrue, reason: 'le chip doit porter un libellé, pas seulement une icône'); // ── 5-quinquies. Les paliers viennent de la BOX, pas d'un calcul de l'app ── // // `+etm16` publie `mechanism.stagesW`. Une seule fonction moteur sert la construction // du routeur, la comparaison matérielle et cette publication : l'app n'a plus de // combinatoire à refaire, donc plus de divergence possible. // Attendre un cycle : les écritures du préambule viennent de republier la télémétrie, // et la lire dans la foulée peut tomber entre deux. final teleP = Provider.of(ctx, listen: false); await _attendre( tester, () => teleP.entryFor('chauffe-eau')?.mechanism?.stagesW != null, limite: const Duration(seconds: 150), quoi: 'la publication de stagesW', ); final stages = teleP.entryFor('chauffe-eau')?.mechanism?.stagesW; debugPrint('[appareil] paliers publiés par la box : $stages'); expect(stages, isNotNull, reason: '.75 est en +etm16 : stagesW doit être publié'); expect(stages, [0, 500, 1000, 1500, 2000, 2500, 3000, 3500]); // ── 5-sexies. Les deux champs de mesure sont ARRIVÉS au schéma ────────── // // Ils étaient grisés « pas encore supporté ». `+etm16` les porte : le grisage dérivé // du schéma les a donc activés SEUL, sans nouvelle version de l'app. for (final champ in ['meterThingId', 'sensorThingId']) { final sup = schema.schema.paramSupport( 'NymeaEnergy.SetLoadConfig', ['loadConfigs', '[]', champ]); debugPrint('[appareil] $champ → $sup'); expect(sup, FieldSupport.writable, reason: '.75 est en +etm16 : « $champ » doit être écrivable'); } // ── 5-quater. L'écran de mécanisme, et son sélecteur ACTIF ────────────── // Le bouton est sous la ligne de flottaison : il faut l'amener à l'écran avant de // taper, sinon le geste ne porte sur rien. final boutons = find.text('Configurer'); await tester.ensureVisible(boutons.first); await tester.pump(const Duration(milliseconds: 400)); await tester.tap(boutons.first); await tester.pump(const Duration(milliseconds: 600)); await _attendre(tester, () => find.text('Mécanisme').evaluate().isNotEmpty, quoi: 'l\'ouverture de l\'écran de mécanisme'); for (final m in ['Routeur de relais', 'Modulable', 'SG-Ready']) { expect(find.text(m), findsWidgets, reason: 'le sélecteur de mécanisme doit proposer « $m »'); } // Les sections attendues DÉPENDENT du mécanisme ouvert — c'est tout l'objet de ces // écrans : ils ne se ressemblent pas. « Configurer » ouvre la première carte, et // l'ordre des domaines n'est pas fixe ; on regarde donc ce qui s'est ouvert. final estRelais = find.text('Relais commandés').evaluate().isNotEmpty || find.text('Contacts SG-Ready').evaluate().isEmpty; final attendues = estRelais ? ['Relais commandés', 'Temporisations', 'Compteur dédié', 'Sonde de température', 'Arbitrage'] : ['Contacts SG-Ready', 'Table d\'états', 'Compteur dédié', 'Sonde de température', 'Arbitrage']; final vues = {}; for (var pas = 0; pas < 30 && vues.length < attendues.length; pas++) { for (final t in attendues) { if (find.text(t).evaluate().isNotEmpty) vues.add(t); } if (vues.length == attendues.length) break; await tester.drag( find.byKey(const ValueKey('liste-mecanisme')), const Offset(0, -150)); await tester.pump(const Duration(milliseconds: 250)); } debugPrint('[appareil] écran de mécanisme (${estRelais ? "relais" : "SG-Ready"}) — ' 'sélecteur : Routeur de relais / Modulable / SG-Ready ; sections vues : ' '${vues.join(' · ')} (${vues.length}/${attendues.length})'); for (final t in attendues) { expect(vues, contains(t), reason: 'section « $t » jamais atteinte'); } // On repart sans rien changer. Navigator.of(tester.element(find.text('Arbitrage').first)).pop(); await tester.pump(const Duration(seconds: 1)); // ── 5-octies. RATTACHER les compteurs, par le chemin de l'app ─────────── // // Premier rattachement fait depuis l'écran plutôt que par une sonde RPC. Ce qui se // vérifie ensuite n'est pas que l'écriture est passée — c'est que la box publie // `measuredW` PAR CHARGE, chacune la sienne, et qu'une charge sans compteur ne publie // toujours rien. Ce dernier point compte autant que l'autre : `null` n'est pas 0, et // afficher un zéro ferait passer une absence de mesure pour une absence de // consommation. // (l'écran de mécanisme a déjà été refermé plus haut — dépiler une seconde fois // viderait la pile de navigation) // Qui est qui, par NOM — le rattachement se pense en appareils, pas en UUID. String? thingParNom(String motif) { for (final t in svc.things) { if (t.name.toLowerCase().contains(motif.toLowerCase())) return t.id; } return null; } final compteurEcs = thingParNom('ECS-Meter'); final compteurPac = thingParNom('PAC-Meter'); debugPrint('[appareil] compteurs trouvés — ECS: $compteurEcs · PAC: $compteurPac'); if (compteurEcs != null && compteurPac != null) { // On écrit par le MÊME chemin que le sélecteur de l'écran : patched() sur la map // reçue, puis save(). Piloter la feuille modale du picker n'ajouterait qu'une // fragilité de test, pas une garantie de plus — c'est cette écriture-ci qu'il porte. for (final e in config.entries) { final c = e.id.contains('pac') ? compteurPac : compteurEcs; config.replaceEntry(e.id, e.patched(meterThingId: c)); } await tester.pump(const Duration(milliseconds: 300)); await config.save(); await _attendre( tester, () => config.saveState == LoadSaveState.confirmed || config.saveState == LoadSaveState.rejected, limite: const Duration(seconds: 20), quoi: 'la confirmation du rattachement', ); expect(config.saveState, LoadSaveState.confirmed, reason: 'le rattachement d\'un compteur ne doit pas être refusé'); // Rattacher n'est PAS du matériel : aucune reconstruction, aucun verrou réarmé. for (final e in config.entries) { debugPrint('[appareil] ${e.id} → compteur ${e.meterThingId}'); expect(e.meterThingId, isNotEmpty); } // Laisser passer deux cycles : la mesure arrive avec le cycle d'arbitrage. final tele = Provider.of(ctx, listen: false); await _attendre( tester, () => config.entries .every((e) => tele.entryFor(e.id)?.measuredW != null), limite: const Duration(seconds: 150), quoi: 'la publication de measuredW pour chaque charge', ); final mesures = { for (final e in config.entries) e.id: tele.entryFor(e.id)?.measuredW }; debugPrint('[appareil] measuredW par charge : $mesures'); expect(mesures.values.every((v) => v != null), isTrue); // Ce qui prouve le rattachement PAR CHARGE, c'est que chacune porte un compteur // DIFFÉRENT — pas que les valeurs diffèrent. Exiger des mesures distinctes était une // erreur de ma part : deux compteurs à l'arrêt lisent légitimement 0 W tous les // deux, et le test échouait alors sur une installation parfaitement saine. final compteurs = {for (final e in config.entries) e.id: e.meterThingId}; debugPrint('[appareil] compteur par charge : $compteurs'); expect(compteurs.values.toSet().length, compteurs.length, reason: 'chaque charge doit porter SON propre compteur'); // Et une charge SANS compteur ne publie rien — `null` n'est pas 0. On le vérifie en // détachant, puis on rattache de nouveau : afficher un zéro à la place ferait passer // une absence de mesure pour une absence de consommation. final cible = config.entries.first; final garde = cible.meterThingId; config.replaceEntry(cible.id, cible.patched(meterThingId: '')); await tester.pump(const Duration(milliseconds: 300)); await config.save(); await _attendre( tester, () => config.saveState == LoadSaveState.confirmed, limite: const Duration(seconds: 20), quoi: 'la confirmation du détachement', ); await _attendre( tester, () => tele.entryFor(cible.id)?.measuredW == null, limite: const Duration(seconds: 150), quoi: 'l\'arrêt de publication de measuredW après détachement', ); debugPrint('[appareil] ${cible.id} sans compteur → measuredW = ' '${tele.entryFor(cible.id)?.measuredW}'); // Rendre le rattachement : il est demandé, il reste. final remise = config.entries.firstWhere((e) => e.id == cible.id); config.replaceEntry(cible.id, remise.patched(meterThingId: garde)); await tester.pump(const Duration(milliseconds: 300)); await config.save(); await _attendre( tester, () => config.saveState == LoadSaveState.confirmed, limite: const Duration(seconds: 20), quoi: 'le rétablissement du compteur', ); } else { debugPrint('[appareil] RATTACHEMENT NON EXERCÉ — ' 'ECS-Meter ou PAC-Meter absent de la box.'); } // ── 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é'); }); }