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
220 lines
10 KiB
Dart
220 lines
10 KiB
Dart
/// 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/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';
|
|
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';
|
|
|
|
/// 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
|
|
/// 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é.
|
|
// `/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));
|
|
|
|
// L'écran DOIT être monté : c'est son initState qui branche LoadConfigProvider sur le
|
|
// service et déclenche la lecture. Si la navigation n'a pas eu lieu, le symptôme
|
|
// serait « aucune charge » — un diagnostic trompeur. On vérifie donc d'abord.
|
|
await _attendre(
|
|
tester,
|
|
() => find.byType(RolesDevicesScreen).evaluate().isNotEmpty,
|
|
quoi: 'le montage de l\'écran « Rôles & appareils »',
|
|
);
|
|
debugPrint('[appareil] écran de configuration monté');
|
|
|
|
final config = Provider.of<LoadConfigProvider>(ctx, listen: false);
|
|
await _attendre(tester, () => config.entries.isNotEmpty,
|
|
limite: const Duration(seconds: 40),
|
|
quoi: 'les charges de GetLoadConfig '
|
|
'(erreur du provider : ${config.error ?? "aucune"})');
|
|
|
|
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.
|
|
// 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é');
|
|
});
|
|
}
|