diff --git a/integration_test/ui_3g2_on_device_test.dart b/integration_test/ui_3g2_on_device_test.dart new file mode 100644 index 0000000..7e26a6a --- /dev/null +++ b/integration_test/ui_3g2_on_device_test.dart @@ -0,0 +1,243 @@ +/// 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` **n'est montée par aucun écran** +/// (constaté le 2026-08-27 — le tableau de bord utilise `features/dashboard/widgets/`). +/// Il n'y a donc rien à regarder, et prétendre l'avoir vue serait faux. +/// +/// 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'); + } + + 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'); + + // ── 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) { + final depart = tester.getCenter(poignees.first); + final geste = await tester.startGesture(depart); + await tester.pump(const Duration(milliseconds: 300)); // arme le long-press + for (var i = 0; i < 12; i++) { + await geste.moveBy(const Offset(0, 12)); + await tester.pump(const Duration(milliseconds: 30)); + } + await geste.up(); + await tester.pump(const Duration(milliseconds: 600)); + + final ordreApres = config.groups.map((g) => g.domain).toList(); + debugPrint('[appareil] glissement : $ordreAvant → $ordreApres'); + 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)); + }); +}