From da0c87e3039668c0780d5052ff8954cd5c18809c Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Thu, 27 Aug 2026 14:54:27 +0200 Subject: [PATCH] test(appareil): le harnais des surfaces 3g-2, et le cadrage des zones de clim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le test d'appareil est ÉCRIT ET NON PASSÉ : l'installation est refusée par MIUI (INSTALL_FAILED_USER_RESTRICTED — « Installer via USB » remis à zéro, troisième fois). Ni adb install, ni pm install depuis /data/local/tmp ne passent : c'est une restriction utilisateur, elle demande le toggle sur le téléphone. Rien de ce lot n'a donc été vu à l'écran, et c'est dit plutôt que contourné. Ce qu'il ira chercher, et qu'aucun test unitaire ne montrera : - les débordements de mise en page, RÉCOLTÉS tous ensemble via FlutterError.onError plutôt qu'échoués au premier — un « overflowed by » ne fait échouer aucun test de ce dépôt, parce qu'aucun ne rend ces widgets à une taille d'écran réelle ; - la taille des cibles tactiles, mesurée sur l'écran réel, avec un plancher dur sur la poignée de glissement : c'est le seul geste de cet écran qui n'a pas de repli ; - le glissement AU DOIGT, en appui maintenu puis mouvements successifs — un tester.drag simple « passe » sans rien réordonner, ce qui avait déjà fait croire à un geste avalé. Exercé puis ABANDONNÉ : le geste est ce qu'on veut voir, pas l'écriture. Il n'exerce pas la carte véhicule, et pour une raison qu'il fallait constater : EVChargingCard n'est montée par AUCUN écran. Le tableau de bord utilise features/dashboard/widgets/. Le SOC inventé à 62 % que j'ai retiré hier n'était donc affiché nulle part — le défaut était réel, sa portée non. Cadrage des zones de clim, avant toute maquette. Le constat qui commande le lot : le banc ne peut pas héberger une zone, et ce n'est pas « il manque un appareil ». Sur les 58 classes de .75, AUCUNE ne porte thermostat, closablesensor, temperaturesensor ni notifications — aucun plugin installé ne sait en créer une. Deux paquets du dépôt apt du banc suffisent, et le document donne l'exigence d'interface par emplacement, lue dans verifyThingIds() plutôt que devinée. Avec une réserve trouvée en chemin : valves est au schéma mais n'est PAS vérifié par la box — un emplacement qui accepte n'importe quoi sans le contrôler. L'écran ac_screen.dart n'appelle pas rien : il appelle GetZones, et seulement pour compter. Ses quatre pièces restent de la maquette, avec huit températures crédibles affichées comme des mesures — le même défaut que le SOC à 62 %, en plus grand. Et le brief plugin, complété entre-temps, tranche la question de la cible aveugle : le carBatteryLevel auquel targetPercentage se compare est une valeur que le MOTEUR écrit lui-même, publiée sous un nom de mesure. L'avancement s'affichera en énergie livrée. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016vrTVifar2GN5rtUh89Wkq --- integration_test/ui_3g2_on_device_test.dart | 243 ++++++++++++++++++++ 1 file changed, 243 insertions(+) create mode 100644 integration_test/ui_3g2_on_device_test.dart 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)); + }); +}