/// Les surfaces changées par 3g-2, **regardées sur le téléphone**. /// /// ## Pourquoi ce test existe séparément /// /// Les tests unitaires du lot 3g-2 prouvent des contrats : qu'une borne ne tombe pas dans /// le repli « modulable », que le réaplatissage ne touche que `priority`, que la note de /// rang disparaît au premier réglage. Aucun d'eux ne peut montrer un texte qui déborde, /// une cible tactile trop petite, ou un glissement qui n'accroche pas sous le doigt. /// C'est exactement ce que ce fichier va chercher. /// /// `adb shell input tap` est refusé sur le Redmi Note 9S (verrou MIUI) : on passe donc par /// `integration_test`, qui s'exécute **dans le processus de l'app** et tape sur les widgets /// depuis l'intérieur. /// /// ## Ce qu'il n'exerce PAS, et pourquoi /// /// La carte véhicule n'y figure pas : `EVChargingCard` **a été supprimée** le 2026-08-28. /// Montée par aucun écran depuis sa création, elle affichait trois grandeurs que l'app ne /// lit pas (mode, puissance, part solaire — des valeurs par défaut du modèle) et proposait /// un réglage de courant minimum qui n'existe dans aucun contrat. Ce qu'une vraie carte de /// recharge doit lire est écrit dans `TODO.md` ; il n'y a rien à exercer d'ici là. /// /// Le glissement est exercé **puis abandonné** (`discardDraft`) : le geste est ce qu'on /// veut voir, pas l'écriture. Réordonner pour de bon changerait l'ordre d'arbitrage d'une /// installation qui n'appartient pas au test. /// /// Lancement : /// ```sh /// flutter test integration_test/ui_3g2_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/load_mechanism_screen.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/load_priority.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'; const _hote = '192.168.1.75'; const _pinInstallateur = '1234'; /// Cible tactile minimale retenue. Android recommande 48 dp ; 44 est le plancher au-dessous /// duquel un pouce rate régulièrement. On mesure tout et on n'échoue que sur la POIGNÉE de /// glissement : c'est le seul geste de cet écran qui n'a pas de solution de repli. const double _cibleMini = 44; 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'); } /// Tient l'écran affiché — sans quoi il n'y a matériellement rien à voir ni à capturer. Future _tenir(WidgetTester tester, String quoi, {int secondes = 12}) async { debugPrint('[appareil] ÉCRAN « $quoi » — maintenu ${secondes}s'); for (var i = 0; i < secondes * 4; i++) { await tester.pump(const Duration(milliseconds: 250)); } } void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); testWidgets('les surfaces 3g-2, sur appareil, contre .75', (tester) async { // ── Collecte des débordements de mise en page ──────────────────────────── // // Un `RenderFlex overflowed by … pixels` part par `FlutterError.onError` et se voit à // l'écran comme une bande rayée. Il ne fait échouer AUCUN test unitaire de ce dépôt, // parce qu'aucun ne rend ces widgets à une taille d'écran réelle. On les récolte // pendant tout le passage et on tranche à la fin. final debordements = []; final precedent = FlutterError.onError; FlutterError.onError = (details) { final texte = details.exceptionAsString(); if (texte.contains('overflowed by')) { final ligne = texte.split('\n').first; if (!debordements.contains(ligne)) debordements.add(ligne); return; // récolté, pas propagé : on veut la LISTE, pas le premier } precedent?.call(details); }; addTearDown(() => FlutterError.onError = precedent); app.main(); await tester.pump(const Duration(seconds: 3)); 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'); final schema = Provider.of(ctx, listen: false); await _attendre(tester, () => schema.schema.loaded, limite: const Duration(seconds: 30), quoi: 'la lecture du schéma'); debugPrint('[appareil] schéma lu — ${schema.versionLine()}'); 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); GoRouter.of(tester.element(find.byType(Scaffold).first)).go('/energy/setup'); await tester.pump(const Duration(seconds: 3)); await _attendre( tester, () => find.byType(RolesDevicesScreen).evaluate().isNotEmpty, quoi: 'le montage de « Rôles & appareils »', ); final config = Provider.of(ctx, listen: false); await _attendre(tester, () => config.entries.isNotEmpty, limite: const Duration(seconds: 40), quoi: 'les charges de GetLoadConfig'); // ── 1. Les bornes SONT dans la liste, avec un rang ─────────────────────── final bornes = config.entries.where((e) => e.isEvCharger).toList(); debugPrint('[appareil] ${config.entries.length} charges, ' 'dont ${bornes.length} bornes : ${bornes.map((b) => b.label).toList()}'); expect(bornes, isNotEmpty, reason: '.75 en +etm23 crée d\'office une entrée par borne détectée'); // ── 2. La note de rang ex æquo se VOIT ─────────────────────────────────── final exAequo = tiedByPriority(config.entries); debugPrint('[appareil] rangs ex æquo : ' '${exAequo.map((e) => "${e.label}=${e.priority}").toList()}'); if (exAequo.isNotEmpty) { // Elle est en tête de section : atteignable sans défiler. expect(find.textContaining('Rangs à égalité'), findsOneWidget, reason: 'l\'ordre affiché avant réglage est un artefact — il doit se dire'); final taille = tester.getSize(find.textContaining('Rangs à égalité')); debugPrint('[appareil] note de rang : ${taille.width}×${taille.height} px'); } // Descendre jusqu'aux charges : dans un `ListView`, ce qui n'est pas à l'écran n'est // pas construit, et l'en-tête des rôles, la ligne de schéma puis la carte de réserve // batterie occupent tout le haut. Sans ce défilement, la capture ne montre pas la // section qu'on prétend regarder — et les cibles tactiles ne sont pas mesurables, // faute d'être montées. for (var pas = 0; pas < 25; pas++) { if (find.text('Configurer').evaluate().length >= 2) break; await tester.drag(find.byType(Scrollable).first, const Offset(0, -220)); await tester.pump(const Duration(milliseconds: 200)); } expect(find.text('Configurer'), findsWidgets, reason: 'les cartes de charge doivent être atteignables'); await _tenir(tester, 'liste des charges + note de rang'); // ── 3. Les cibles tactiles, mesurées à la taille d'écran RÉELLE ────────── final poignees = find.byWidgetPredicate( (w) => w is ReorderableDragStartListener, skipOffstage: true); expect(poignees, findsWidgets, reason: 'les domaines doivent être saisissables'); var minPoignee = double.infinity; for (var i = 0; i < poignees.evaluate().length; i++) { final t = tester.getSize(poignees.at(i)); minPoignee = [minPoignee, t.width, t.height].reduce((a, b) => a < b ? a : b); debugPrint('[appareil] poignée $i : ${t.width}×${t.height} px'); } expect(minPoignee, greaterThanOrEqualTo(_cibleMini), reason: 'la poignée de glissement est le seul geste sans repli de cet écran : ' 'sous $_cibleMini px, un pouce la rate'); // Les flèches de rang — l'autre commande tactile de cet écran, et elle n'a pas de // repli non plus : réordonner DANS un domaine ne se fait que par elles. final fleches = find.byWidgetPredicate( (w) => w is InkWell && w.borderRadius != null, skipOffstage: true); var minFleche = double.infinity; for (var i = 0; i < fleches.evaluate().length; i++) { final t = tester.getSize(fleches.at(i)); if (t.width > 200) continue; // pas une flèche : une carte cliquable minFleche = [minFleche, t.width, t.height].reduce((a, b) => a < b ? a : b); } if (minFleche.isFinite) { debugPrint('[appareil] plus petite flèche de rang : $minFleche px'); expect(minFleche, greaterThanOrEqualTo(_cibleMini)); } // ── 4. Le glissement AU DOIGT — geste réel, écriture abandonnée ────────── // // `tester.drag` pose le pointeur et le relâche en un coup ; une liste réordonnable // exige un appui MAINTENU puis des mouvements successifs pour armer le glissement. // Un `drag` simple « passe » sans rien réordonner — c'est ce qui avait fait croire à // un geste avalé lors de l'enquête précédente. final ordreAvant = config.groups.map((g) => g.domain).toList(); if (ordreAvant.length >= 2) { // AMENER la première poignée dans l'écran, et le vérifier. // // Piège payé deux fois : `find` trouve un widget **posé mais hors du viewport**, et // `getCenter` en rend alors une coordonnée qui n'existe pas à l'écran (mesuré : // y = 1129 sur un écran de ${tester.view.physicalSize.height ~/ tester.view.devicePixelRatio} px de haut). // Le geste part dans le vide et le symptôme est identique à un glissement ignoré. // On positionne donc jusqu'à ce que la poignée soit RÉELLEMENT visible. final hauteur = tester.view.physicalSize.height / tester.view.devicePixelRatio; for (var pas = 0; pas < 30; pas++) { final y = tester.getCenter(poignees.at(0)).dy; if (y > 140 && y < hauteur * 0.4) break; await tester.drag( find.byType(Scrollable).first, Offset(0, y > hauteur * 0.4 ? -160 : 160)); await tester.pump(const Duration(milliseconds: 150)); } // La distance n'est PAS arbitraire : une liste réordonnable ne permute qu'une fois // l'élément traîné passé le milieu du suivant. Un bloc de domaine porte ses cartes, // donc il est haut — plusieurs centaines de pixels. Un déplacement « de quelques // dizaines de pixels » ne franchit rien. On mesure donc l'écart réel entre deux // poignées. final p0 = tester.getCenter(poignees.at(0)); final p1 = tester.getCenter(poignees.at(1)); final course = (p1.dy - p0.dy) + 32; debugPrint('[appareil] écran ${hauteur.round()} px · poignées à ' '${p0.dy.round()} et ${p1.dy.round()} · course ${course.round()} px'); expect(p0.dy, lessThan(hauteur), reason: 'la poignée saisie doit être DANS l\'écran, pas seulement dans l\'arbre'); // Diagnostic : QUI prend le geste ? Si la page défile pendant le glissement, c'est // le `ListView` extérieur qui a gagné l'arène — et le symptôme (« rien ne bouge ») // est identique à celui d'une poignée qui n'accroche pas, pour une cause opposée. double offsetPage() => tester.state(find.byType(Scrollable).first).position.pixels; final avantOffset = offsetPage(); final geste = await tester.startGesture(p0); await tester.pump(const Duration(milliseconds: 400)); // arme la prise const pasDeGeste = 20; for (var i = 0; i < pasDeGeste; i++) { await geste.moveBy(Offset(0, course / pasDeGeste)); await tester.pump(const Duration(milliseconds: 40)); } await tester.pump(const Duration(milliseconds: 300)); await geste.up(); await tester.pump(const Duration(seconds: 1)); final ordreApres = config.groups.map((g) => g.domain).toList(); debugPrint('[appareil] glissement : $ordreAvant → $ordreApres · ' 'défilement de la page ${(offsetPage() - avantOffset).round()} px'); expect(ordreApres, isNot(ordreAvant), reason: 'le glissement au doigt doit réordonner les domaines'); // Le banc n'appartient pas au test : rien n'est écrit. config.discardDraft(); await tester.pump(const Duration(milliseconds: 400)); expect(config.hasPendingChanges, isFalse); debugPrint('[appareil] brouillon abandonné — rien n\'est parti sur la box'); } else { debugPrint('[appareil] GLISSEMENT NON EXERCÉ — un seul domaine sur ce banc'); } // ── 5. L'écran de mécanisme d'une BORNE ────────────────────────────────── // // C'est la surface la plus neuve, et celle qui doit surtout NE PAS proposer ce // qu'elle proposait : puissance nominale, paliers, et une bascule vers routeur de // relais dont aucun écran ne sait revenir. final borne = bornes.first; Navigator.of(tester.element(find.byType(RolesDevicesScreen))) .push(MaterialPageRoute( builder: (_) => LoadMechanismScreen(loadId: borne.id))); await tester.pump(const Duration(seconds: 2)); await _attendre( tester, () => find.byType(LoadMechanismScreen).evaluate().isNotEmpty, quoi: 'le montage de l\'écran de mécanisme de « ${borne.label} »', ); expect(find.textContaining('imposé · evcharger'), findsOneWidget, reason: 'le mécanisme d\'une borne est imposé, pas au choix'); expect(find.text('Routeur de relais'), findsNothing, reason: 'offrir la bascule serait un aller sans retour en deux gestes'); expect(find.text('SG-Ready'), findsNothing); expect(find.textContaining('Borne'), findsWidgets); await _tenir(tester, 'écran de mécanisme d\'une borne'); // ── 6. Verdict de mise en page ─────────────────────────────────────────── if (debordements.isNotEmpty) { debugPrint('[appareil] DÉBORDEMENTS (${debordements.length}) :'); for (final d in debordements) { debugPrint('[appareil] • $d'); } } else { debugPrint('[appareil] aucun débordement de mise en page'); } expect(debordements, isEmpty, reason: 'un texte qui déborde se voit sur le téléphone et nulle part ailleurs'); installateur.lock(); await tester.pump(const Duration(milliseconds: 500)); }); }