test(appareil): harnais integration_test — l'IU sur le téléphone, contre la vraie box

adb shell input tap est refusé par MIUI (INJECT_EVENTS). integration_test contourne le
problème par construction : le test s'exécute DANS le processus de l'app et tape sur les
widgets depuis l'intérieur, sans injection d'événements Android.

Ce que le harnais a déjà prouvé SUR L'APPAREIL, contre .75 : l'app démarre, se connecte
(« connecté à 1.15.2+202606191336~trixie1 ») et SchemaProvider lit JSONRPC.Introspect
(« NymeaEnergy 0.8 · AirConditioning 1.1 »). Le grisage dérivé du schéma n'est donc pas
une théorie : il est alimenté sur le téléphone par la vraie box.

Le run n'est pas allé au bout. Trois obstacles : GoRouter.of() cherché au-dessus du Router
(corrigé) ; le test supposait une installation déjà enregistrée (corrigé — il enregistre la
box lui-même via connectNew) ; puis le téléphone s'est déconnecté du bus USB, et l'ADB sans
fil n'est pas activé. C'est là que ça s'est arrêté.

EFFET DE BORD ASSUMÉ : flutter test integration_test RÉINSTALLE l'app, ce qui vide
shared_preferences et le stockage sécurisé — les deux installations enregistrées sur le
téléphone ont été effacées. Le harnais ne dépend plus de cet état et restaurera .75 au
premier run mené à terme ; nymea-dev-kutz est à rajouter à la main.

Relevé mis à jour : docs/RELEVE_LOTC.md §2 et §6.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff
This commit is contained in:
Patrick Schurig 2026-08-25 22:42:09 +02:00
parent 2a8ad95120
commit f0aa0fd804
4 changed files with 267 additions and 14 deletions

View File

@ -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.

View File

@ -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<void> _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<NymeaService>(ctx, listen: false);
final cnx = Provider.of<ConnectionManager>(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<SchemaProvider>(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<LoadConfigProvider>(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));
}
});
}

View File

@ -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:

View File

@ -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