1) GLISSEMENT — diagnostic d'abord, et il a donné DEUX réponses. Le geste n'est PAS avalé par le ListView parent : les deux montages du test (section seule / section imbriquée) glissent identiquement. La cause du symptôme était bien que .75 n'a qu'une charge par domaine — rien à réordonner. Mais l'enquête a trouvé un vrai défaut au passage : le centre de la poignée tombait dans le vide de 3 px séparant l'icône de la pastille de rang, et ReorderableDragStartListener défère le test de collision à ses enfants. Une bande morte invisible au milieu d'une poignée qui paraît d'un seul tenant. Corrigé par un Container transparent. Tentative faite et ANNULÉE : rendre les domaines glissables aussi. Deux ReorderableListView imbriqués se disputent le geste et l'extérieur gagne — le glissement des cartes cassait. Les domaines gardent leurs flèches, et une charge seule dans son domaine n'offre plus de prise qui mènerait nulle part (avec l'infobulle qui renvoie au bon geste). Vérifié SUR L'APPAREIL, contre .75, avec les deux charges mises dans le même domaine : [chauffe-eau, pac-terrain] → [pac-terrain, chauffe-eau], brouillon abandonné sans rien écrire. Classement ecs/hvac rétabli ensuite. 2) SÉLECTEUR DE MÉCANISME — actif, et c'était le morceau délicat. LoadConfig est une union discriminée : écrire `adapter` sans nettoyer le reste produit une configuration que la box refuse EN BLOC, et le refus emporte toutes les charges de l'appel. D'où basculerMecanisme(), qui retire les clés du mécanisme quitté et pose celles du nouveau, en récupérant ce qui peut l'être — les deux premiers contacts deviennent SG1/SG2 dans l'encodage standard, les relais gardent leur puissance. Ce qui n'est PAS transposé : l'estimation d'un état SG-Ready n'est pas la puissance d'une résistance. La charge produite est alors incomplète, et c'est voulu — le formulaire réclame plutôt que d'inventer une valeur de plaque. La bascule franchit sameHardware() : confirmation préalable qui annonce la reconstruction et la durée de réarmement (900 s par défaut vers SG-Ready — la valeur d'une PAC réelle, pas celle du banc). Le mode fixed d'etmvariableload reste LISIBLE sans être proposé : bloc « Configuration héritée » en lecture seule, et conversion seulement sur geste explicite, avec le plafond que la déclaration en paliers ne porte pas. Ouvrir l'écran ne convertit personne. 3) CHIP DE DOMAINE — il ne rendait qu'une icône muette : le domaine d'une charge était illisible sur sa carte, alors qu'il décide de son groupe et de son rang. Il porte maintenant son libellé, et sa couleur suit le thème. 4) THÈME — les 11 derniers sites (pas 9) basculés. Trois cas : contexte disponible (main, ac_screen) ; peintre sans contexte, à qui la couleur est passée résolue (energy_flow_widget) ; tables statiques, où la teinte devient nullable et se résout au rendu (categoryInfoMap, favoris). Plus AUCUN jeton hors thème dans lib/. 5) ZONES — non entreprises, comme convenu. Tests 93/93, analyze 0 erreur. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff
270 lines
13 KiB
Dart
270 lines
13 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}');
|
|
|
|
// ── 5-bis. LE GLISSEMENT, sur l'appareil, contre la vraie box ───────────
|
|
//
|
|
// Le test décisif. Il n'a de sens que si les deux charges partagent un domaine —
|
|
// sinon il n'y a rien à réordonner, et c'est précisément ce qui donnait l'impression
|
|
// que « rien ne fonctionne ». Poser le classement se fait hors de ce test
|
|
// (`tools/rpc/domain_assign.dart --set chauffe-eau=ecs --set pac-terrain=ecs`) ; ici
|
|
// on constate, et on saute proprement si la condition n'est pas réunie.
|
|
final parDomaine = <String, int>{};
|
|
for (final e in config.entries) {
|
|
parDomaine.update(e.domain ?? '', (v) => v + 1, ifAbsent: () => 1);
|
|
}
|
|
final memeDomaine = parDomaine.values.any((n) => n > 1);
|
|
debugPrint('[appareil] charges par domaine : $parDomaine');
|
|
|
|
if (!memeDomaine) {
|
|
debugPrint('[appareil] GLISSEMENT NON EXERCÉ — une seule charge par domaine, '
|
|
'il n\'y a rien à réordonner. Poser deux charges dans un même domaine, '
|
|
'puis relancer.');
|
|
} else {
|
|
final ordreAvant = [
|
|
for (final g in config.groups) ...g.loads.map((e) => e.id)
|
|
];
|
|
final premier = ordreAvant.first;
|
|
final poignee = find.byKey(ValueKey('poignee-charge-$premier'));
|
|
final cible = find.byKey(ValueKey('load-${ordreAvant[1]}'));
|
|
expect(poignee, findsOneWidget, reason: 'poignée de $premier absente');
|
|
|
|
final dy = tester.getSize(cible).height * 2;
|
|
final g = await tester.startGesture(tester.getCenter(poignee));
|
|
await tester.pump(const Duration(milliseconds: 60));
|
|
for (var i = 0; i < 12; i++) {
|
|
await g.moveBy(Offset(0, dy / 12));
|
|
await tester.pump(const Duration(milliseconds: 16));
|
|
}
|
|
await g.up();
|
|
await tester.pump(const Duration(milliseconds: 400));
|
|
|
|
final ordreApres = [
|
|
for (final g in config.groups) ...g.loads.map((e) => e.id)
|
|
];
|
|
debugPrint('[appareil] glissement : $ordreAvant → $ordreApres');
|
|
expect(ordreApres.first, isNot(premier),
|
|
reason: 'le glissement doit réordonner sur l\'appareil aussi');
|
|
|
|
// Le brouillon ne part PAS : le rang du banc n'appartient pas au test.
|
|
config.discardDraft();
|
|
await tester.pump(const Duration(milliseconds: 200));
|
|
debugPrint('[appareil] brouillon abandonné — rien n\'est écrit sur la box');
|
|
}
|
|
|
|
// ── 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é');
|
|
});
|
|
}
|