test(appareil): l'IU de configuration tourne sur le téléphone, contre .75

Chaîne complète vérifiée sur l'appareil : schéma lu, écran fusionné monté, charges réelles
affichées ([chauffe-eau, pac-terrain]) avec leur télémétrie, écriture confirmée par
LoadConfigChanged, restauration.

Le dernier obstacle était le plus instructif : /energy/setup est une route réservée au mode
installateur, et le routeur la renvoie vers / tant qu'il est verrouillé. Le symptôme était
« aucune charge lue » — on aurait cherché un défaut de lecture RPC là où le verrou faisait
son travail. C'est le contrôle de montage d'écran ajouté au harnais qui a tranché.

Le test déverrouille avec le PIN par défaut et reverrouille APRÈS la pause d'observation :
l'ordre inverse passait les assertions mais renvoyait l'écran au tableau de bord.

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

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 23:00:58 +02:00
parent 98e2874ecd
commit 15a52637a5
2 changed files with 67 additions and 55 deletions

View File

@ -122,54 +122,44 @@ implémentations de la même combinatoire divergeraient, et c'est l'app qui aura
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é
### Le pilotage de l'IU : FAIT, de bout en bout
`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.
`adb shell input tap` était refusé par MIUI
(`SecurityException: … requires INJECT_EVENTS permission`). La voie retenue est
`integration_test` : 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. Chaîne complète, sur
l'appareil, contre `.75` :
Il n'a **pas** été mené jusqu'au bout. Trois obstacles, dans l'ordre où ils sont apparus :
```
[appareil] schéma lu — nymea 1.15.2+202606191336~trixie1 · NymeaEnergy 0.8 · AirConditioning 1.1
[appareil] mode installateur déverrouillé
[appareil] écran de configuration monté
[appareil] charges lues : [chauffe-eau, pac-terrain]
[appareil] écriture → LoadSaveState.confirmed
[appareil] libellé restauré : chauffe-eau
```
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é) ;
4. rebranché, le run est allé **plus loin que jamais** — connexion, schéma lu — et a buté
sur la lecture des charges. Un diagnostic de montage d'écran a été ajouté pour trancher
entre « navigation non faite » et « lecture en échec »… mais il n'a pas pu s'exécuter :
**MIUI refuse désormais toute installation par USB.**
Sont donc vérifiés **sur l'appareil** : la lecture du schéma, l'affichage de l'écran
fusionné avec les charges réelles et leur télémétrie, une **écriture confirmée par
`LoadConfigChanged`** (et non par l'accusé du RPC), et sa restauration.
```
adb: failed to install app-debug.apk:
Failure [INSTALL_FAILED_USER_RESTRICTED: Install canceled by user]
```
Quatre obstacles ont été levés en chemin, tous instructifs :
Aucune boîte de dialogue à l'écran : le refus est silencieux. Le réglage
« Installer via USB » des options développeur MIUI s'est remis à zéro à la reconnexion
— comportement connu de MIUI, et il exige un compte Xiaomi pour être réactivé.
**Conséquence immédiate : l'application n'est plus installée du tout** (`pm list
packages` ne la trouve plus), l'installation ayant échoué après la désinstallation.
1. `GoRouter.of()` cherché sur le contexte du `MaterialApp`, **au-dessus** du Router ;
2. le test supposait une installation déjà enregistrée — il la **pose lui-même**
(`connectNew`), car `flutter test integration_test` réinstalle l'app et vide son
stockage ;
3. `INSTALL_FAILED_USER_RESTRICTED` — MIUI avait remis à zéro « Installer via USB » à la
reconnexion du câble ; réactivé à la main sur le téléphone ;
4. **`/energy/setup` est une route réservée au mode installateur.** Le routeur la renvoie
vers `/` tant qu'il est verrouillé. Le symptôme était « aucune charge lue » — un
diagnostic parfaitement trompeur, qui aurait fait chercher un défaut de lecture RPC là
où le verrou faisait simplement son travail. C'est le contrôle de **montage d'écran**
ajouté au harnais qui a tranché.
**C'est là que ça s'est arrêté**, et le blocage n'est pas logiciel.
> ⚠️ **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.
> Le test déverrouille avec le PIN par défaut (`1234`), et **reverrouille après** — dans
> cet ordre. L'inverse passait tous les tests mais renvoyait l'écran au tableau de bord
> avant la capture : le verrou marchait trop bien.
---
@ -260,19 +250,12 @@ le banc pour être vérifié.
## 6. Ce qui n'a PAS été vérifié, et pourquoi
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* : réactiver **« Installer via USB »** dans les options
développeur du téléphone (MIUI l'a remis à zéro), puis
`flutter test integration_test/config_on_device_test.dart -d dee659e2`. Le harnais est
écrit et instrumenté ; il ne reste qu'à le faire tourner. Activer au passage
« Débogage USB (réglages de sécurité) » déverrouillerait aussi `adb shell input`, utile
mais non nécessaire ici.
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.
1. ~~Les écrans de configuration sur l'appareil~~ — **fait**, voir §2.
2. **Les écritures MATÉRIELLES depuis l'IU** (réordonner des contacteurs, désactiver une
charge) n'ont pas été déclenchées sur l'appareil : elles ouvrent des contacts et
réarment des verrous, et le banc n'appartient pas au test. Seul un renommage — sans
effet matériel — a été écrit puis restauré. L'annonce d'impact reste donc couverte en
tests de widgets sur les dumps réels (`merged_config_screen_test.dart`).
3. **Le grisage n'a pas été observé sur une box « ancienne »** — il n'y en a pas sous la main.
Le comportement en champ absent est couvert en test à partir d'un schéma tronqué, pas d'une
vraie box antérieure.

View File

@ -24,6 +24,7 @@
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/roles_devices_screen.dart';
@ -38,6 +39,9 @@ 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';
/// PIN installateur par défaut, posé au premier démarrage tant qu'aucun n'a été choisi.
const _pinInstallateur = '1234';
/// 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
@ -97,6 +101,20 @@ void main() {
//
// 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é.
// `/energy/setup` est une route RÉSERVÉE AU MODE INSTALLATEUR : le routeur la renvoie
// vers `/` tant qu'il est verrouillé (`_isInstallerRoute`). Ce n'est pas un défaut —
// c'est le même verrou qui masque l'ajout d'installation. Sans déverrouillage, la
// navigation « réussit » sans rien monter, et le symptôme observé est « aucune
// charge » : un diagnostic parfaitement trompeur, d'où le contrôle de montage plus bas.
final installateur = Provider.of<InstallerModeProvider>(ctx, listen: false);
if (!installateur.isUnlocked) {
await installateur.unlock(_pinInstallateur);
await tester.pump(const Duration(milliseconds: 500));
}
expect(installateur.isUnlocked, isTrue,
reason: 'le PIN installateur par défaut doit ouvrir les écrans de configuration');
debugPrint('[appareil] mode installateur déverrouillé');
final ctxSousRouteur = tester.element(find.byType(Scaffold).first);
GoRouter.of(ctxSousRouteur).go('/energy/setup');
await tester.pump(const Duration(seconds: 3));
@ -182,9 +200,20 @@ void main() {
// 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.
// ORDRE IMPORTANT : on tient l'écran D'ABORD, on reverrouille APRÈS.
//
// Reverrouiller avant la pause renvoie immédiatement vers `/` — `/energy/setup` est
// une route installateur et le `redirect` du routeur s'applique en continu. La
// première version faisait l'inverse : le test passait, mais la capture montrait le
// tableau de bord. Le verrou marchait trop bien.
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));
}
// Le mode installateur ne reste pas ouvert derrière le test.
installateur.lock();
await tester.pump(const Duration(milliseconds: 500));
debugPrint('[appareil] mode installateur reverrouillé');
});
}