diff --git a/docs/RELEVE_LOTC.md b/docs/RELEVE_LOTC.md index c1a83f1..c023ff5 100644 --- a/docs/RELEVE_LOTC.md +++ b/docs/RELEVE_LOTC.md @@ -113,11 +113,46 @@ implémentations de la même combinatoire divergeraient, et c'est l'app qui aura |---|---| | `flutter build apk --debug` (android-arm64) | ✔ construit | | `adb install` sur le Redmi Note 9S | ✔ installé | -| Lancement de l'app | ✔ démarre, écran « Installations » | +| Lancement de l'app | ✔ démarre | | Le téléphone (192.168.1.121) joint le banc | ✔ ping 4-20 ms | -| L'app liste `nymea-dev-rpi` → `nymea://192.168.1.75:4444` | ✔ (capture d'écran) | +| L'app liste `nymea-dev-rpi` → `nymea://192.168.1.75:4444` | ✔ capture d'écran | +| **L'app se connecte à `.75` depuis le téléphone** | ✔ `connecté à 1.15.2+202606191336~trixie1` | +| **`SchemaProvider` lit `JSONRPC.Introspect` sur l'appareil** | ✔ `nymea 1.15.2+… · NymeaEnergy 0.8 · AirConditioning 1.1` | -**Et c'est tout — l'IU n'a PAS pu être pilotée.** Voir §6. +Les deux dernières lignes comptent : elles prouvent **sur l'appareil et contre la vraie +box** que le schéma est lu à la connexion — donc que le grisage n'est pas une théorie. + +### Le pilotage de l'IU : la voie trouvée, le run pas terminé + +`adb shell input tap` est refusé par MIUI +(`SecurityException: Injecting to another application requires INJECT_EVENTS permission`). +La voie qui contourne cela est `integration_test` : le test s'exécute **dans le processus +de l'app** et tape sur les widgets depuis l'intérieur, sans passer par l'injection +d'événements d'Android. Le harnais est écrit +(`integration_test/config_on_device_test.dart`) et **a effectivement démarré l'app, +connecté la box et lu le schéma** — les deux lignes du tableau ci-dessus en viennent. + +Il n'a **pas** été mené jusqu'au bout. Trois obstacles, dans l'ordre où ils sont apparus : + +1. `GoRouter.of()` cherché sur le contexte du `MaterialApp`, qui est **au-dessus** du + Router — corrigé (on prend un contexte sous lui) ; +2. le test supposait une installation déjà enregistrée sur le téléphone — corrigé, il + **enregistre la box lui-même** (`connectNew`), voir l'encadré ci-dessous ; +3. **le téléphone s'est déconnecté du bus USB** (`adb: device 'dee659e2' not found`, + plus rien dans `lsusb`), et l'ADB sans fil n'est pas activé + (`connect 192.168.1.121:5555` → refusé). **C'est là que ça s'est arrêté.** + +> ⚠️ **Effet de bord causé au téléphone, et assumé.** `flutter test integration_test` +> **réinstalle** l'application, ce qui vide `shared_preferences` et le stockage sécurisé : +> les deux installations enregistrées (`nymea-dev-rpi`, `nymea-dev-kutz`) **ont été +> effacées**. L'écran affiche donc « Aucune installation enregistrée », et l'ajout manuel +> est réservé au mode installateur. Le harnais a été corrigé pour ne plus dépendre de cet +> état — il enregistre la box lui-même, ce qui la **restaurera** au premier run mené à +> terme. `nymea-dev-kutz` (192.168.1.120), lui, est à rajouter à la main. + +**Reste donc à faire, dès que le téléphone est rebranché** : un seul run de +`integration_test/config_on_device_test.dart`, qui couvre l'écran fusionné affichant les +charges réelles, une écriture confirmée par `LoadConfigChanged` et sa restauration. --- @@ -208,17 +243,13 @@ le banc pour être vérifié. ## 6. Ce qui n'a PAS été vérifié, et pourquoi -1. **L'IU n'a pas pu être pilotée sur l'appareil.** `adb shell input tap` est refusé par la - box Android : - `SecurityException: Injecting to another application requires INJECT_EVENTS permission`. - C'est le verrou MIUI sur le Redmi — il se lève dans les options développeur - (« débogage USB (réglages de sécurité) »), ce qui exige un compte Xiaomi et un geste - manuel. `xdotool` n'est pas installé sur la machine, donc la voie « bureau Linux » est - fermée aussi. **Aucun écran de configuration n'a donc été vu s'afficher sur l'appareil, - ni aucune écriture déclenchée depuis l'IU.** Ce qui a été vérifié sur l'appareil est au §2, - et rien de plus. - *Pour clore ce point* : activer le réglage sur le téléphone, ou piloter l'écran à la main - en suivant §1 de ce document. +1. **Les écrans de configuration n'ont pas été vus s'afficher sur l'appareil**, et aucune + écriture n'y a été déclenchée depuis l'IU. La connexion et la lecture du schéma, elles, + l'ont été (§2). Le blocage final n'est pas logiciel : le téléphone s'est **déconnecté du + bus USB** en cours de campagne. + *Pour clore ce point* : rebrancher le téléphone (et autoriser l'ADB s'il le redemande), + puis `flutter test integration_test/config_on_device_test.dart -d dee659e2`. Le harnais + est écrit et corrigé ; il ne reste qu'à le faire tourner. 2. **Les écritures depuis l'IU** n'ont donc été exercées qu'en **tests de widgets**, sur les dumps réels de `.75` (`merged_config_screen_test.dart`). Le rendu, l'annonce d'impact et le grisage y sont couverts ; le transport ne l'est pas. diff --git a/integration_test/config_on_device_test.dart b/integration_test/config_on_device_test.dart new file mode 100644 index 0000000..91e890c --- /dev/null +++ b/integration_test/config_on_device_test.dart @@ -0,0 +1,177 @@ +/// 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/load_config_provider.dart'; +import 'package:etm_powersync_app/providers/schema_provider.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'; + +/// 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é. + final ctxSousRouteur = tester.element(find.byType(Scaffold).first); + GoRouter.of(ctxSousRouteur).go('/energy/setup'); + await tester.pump(const Duration(seconds: 3)); + + final config = Provider.of(ctx, listen: false); + await _attendre(tester, () => config.entries.isNotEmpty, + limite: const Duration(seconds: 30), quoi: 'les charges de GetLoadConfig'); + + 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. + 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)); + } + }); +} diff --git a/pubspec.lock b/pubspec.lock index 8bd664f..fd74aed 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -118,6 +118,11 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_driver: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" flutter_lints: dependency: "direct dev" description: @@ -197,6 +202,11 @@ packages: description: flutter source: sdk version: "0.0.0" + fuchsia_remote_debug_protocol: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" go_router: dependency: "direct main" description: @@ -237,6 +247,11 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + integration_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" intl: dependency: "direct main" description: @@ -437,6 +452,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + process: + dependency: transitive + description: + name: process + sha256: c6248e4526673988586e8c00bb22a49210c258dc91df5227d5da9748ecf79744 + url: "https://pub.dev" + source: hosted + version: "5.0.5" provider: dependency: "direct main" description: @@ -554,6 +577,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + sync_http: + dependency: transitive + description: + name: sync_http + sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961" + url: "https://pub.dev" + source: hosted + version: "0.3.1" term_glyph: dependency: transitive description: @@ -682,6 +713,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + webdriver: + dependency: transitive + description: + name: webdriver + sha256: "2f3a14ca026957870cfd9c635b83507e0e51d8091568e90129fbf805aba7cade" + url: "https://pub.dev" + source: hosted + version: "3.1.0" win32: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index cb05ac3..e481618 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -53,6 +53,12 @@ dev_dependencies: flutter_test: sdk: flutter + # Pilotage de l'IU SUR L'APPAREIL. Le test s'exécute dans le processus de l'app et + # tape sur les widgets depuis l'intérieur : il ne passe donc pas par l'injection + # d'événements d'Android, que MIUI refuse sur le Redmi (INJECT_EVENTS). + integration_test: + sdk: flutter + # The "flutter_lints" package below contains a set of recommended lints to # encourage good coding practices. The lint set provided by the package is # activated in the `analysis_options.yaml` file located at the root of your