etm-powersync-app/integration_test/ui_3g2_on_device_test.dart
Patrick Schurig bedeabf767 fix(banc): l'adresse du banc passe à 10.42.60.21 — sondes, tests on-device, consignes
Le banc `hems` est renuméroté (192.168.1.75 → 10.42.60.21). Portés : les 11
sondes `tools/rpc/*` et leur README, les 3 tests on-device, le `hint` de
l'écran Installations, les procédures de CLAUDE.md et des deux documents de
cadrage.

Ce qui n'est PAS touché, et c'est délibéré : les relevés datés (RELEVE_LOTC,
RECAP, Prompt 3g2, maquettes) et les fixtures `hems75_*.json`. Un relevé daté
dit ce qui a été observé ce jour-là ; le réécrire échange une trace contre une
cohérence de surface. CLAUDE.md porte la note qui fait le lien : `.75` et
`hems75` désignent la même machine.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-13 06:37:33 +02:00

313 lines
16 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/// Les surfaces changées par 3g-2, **regardées sur le téléphone**.
///
/// ## Pourquoi ce test existe séparément
///
/// Les tests unitaires du lot 3g-2 prouvent des contrats : qu'une borne ne tombe pas dans
/// le repli « modulable », que le réaplatissage ne touche que `priority`, que la note de
/// rang disparaît au premier réglage. Aucun d'eux ne peut montrer un texte qui déborde,
/// une cible tactile trop petite, ou un glissement qui n'accroche pas sous le doigt.
/// C'est exactement ce que ce fichier va chercher.
///
/// `adb shell input tap` est refusé sur le Redmi Note 9S (verrou MIUI) : on passe donc par
/// `integration_test`, qui s'exécute **dans le processus de l'app** et tape sur les widgets
/// depuis l'intérieur.
///
/// ## Ce qu'il n'exerce PAS, et pourquoi
///
/// La carte véhicule n'y figure pas : `EVChargingCard` **a été supprimée** le 2026-08-28.
/// Montée par aucun écran depuis sa création, elle affichait trois grandeurs que l'app ne
/// lit pas (mode, puissance, part solaire — des valeurs par défaut du modèle) et proposait
/// un réglage de courant minimum qui n'existe dans aucun contrat. Ce qu'une vraie carte de
/// recharge doit lire est écrit dans `TODO.md` ; il n'y a rien à exercer d'ici là.
///
/// Le glissement est exercé **puis abandonné** (`discardDraft`) : le geste est ce qu'on
/// veut voir, pas l'écriture. Réordonner pour de bon changerait l'ordre d'arbitrage d'une
/// installation qui n'appartient pas au test.
///
/// Lancement :
/// ```sh
/// flutter test integration_test/ui_3g2_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/load_mechanism_screen.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/load_priority.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';
const _hote = '10.42.60.21';
const _pinInstallateur = '1234';
/// Cible tactile minimale retenue. Android recommande 48 dp ; 44 est le plancher au-dessous
/// duquel un pouce rate régulièrement. On mesure tout et on n'échoue que sur la POIGNÉE de
/// glissement : c'est le seul geste de cet écran qui n'a pas de solution de repli.
const double _cibleMini = 44;
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');
}
/// Tient l'écran affiché — sans quoi il n'y a matériellement rien à voir ni à capturer.
Future<void> _tenir(WidgetTester tester, String quoi, {int secondes = 12}) async {
debugPrint('[appareil] ÉCRAN « $quoi » — maintenu ${secondes}s');
for (var i = 0; i < secondes * 4; i++) {
await tester.pump(const Duration(milliseconds: 250));
}
}
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('les surfaces 3g-2, sur appareil, contre .75', (tester) async {
// ── Collecte des débordements de mise en page ────────────────────────────
//
// Un `RenderFlex overflowed by … pixels` part par `FlutterError.onError` et se voit à
// l'écran comme une bande rayée. Il ne fait échouer AUCUN test unitaire de ce dépôt,
// parce qu'aucun ne rend ces widgets à une taille d'écran réelle. On les récolte
// pendant tout le passage et on tranche à la fin.
final debordements = <String>[];
final precedent = FlutterError.onError;
FlutterError.onError = (details) {
final texte = details.exceptionAsString();
if (texte.contains('overflowed by')) {
final ligne = texte.split('\n').first;
if (!debordements.contains(ligne)) debordements.add(ligne);
return; // récolté, pas propagé : on veut la LISTE, pas le premier
}
precedent?.call(details);
};
addTearDown(() => FlutterError.onError = precedent);
app.main();
await tester.pump(const Duration(seconds: 3));
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');
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');
debugPrint('[appareil] schéma lu — ${schema.versionLine()}');
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);
GoRouter.of(tester.element(find.byType(Scaffold).first)).go('/energy/setup');
await tester.pump(const Duration(seconds: 3));
await _attendre(
tester,
() => find.byType(RolesDevicesScreen).evaluate().isNotEmpty,
quoi: 'le montage de « Rôles & appareils »',
);
final config = Provider.of<LoadConfigProvider>(ctx, listen: false);
await _attendre(tester, () => config.entries.isNotEmpty,
limite: const Duration(seconds: 40), quoi: 'les charges de GetLoadConfig');
// ── 1. Les bornes SONT dans la liste, avec un rang ───────────────────────
final bornes = config.entries.where((e) => e.isEvCharger).toList();
debugPrint('[appareil] ${config.entries.length} charges, '
'dont ${bornes.length} bornes : ${bornes.map((b) => b.label).toList()}');
expect(bornes, isNotEmpty,
reason: '.75 en +etm23 crée d\'office une entrée par borne détectée');
// ── 2. La note de rang ex æquo se VOIT ───────────────────────────────────
final exAequo = tiedByPriority(config.entries);
debugPrint('[appareil] rangs ex æquo : '
'${exAequo.map((e) => "${e.label}=${e.priority}").toList()}');
if (exAequo.isNotEmpty) {
// Elle est en tête de section : atteignable sans défiler.
expect(find.textContaining('Rangs à égalité'), findsOneWidget,
reason: 'l\'ordre affiché avant réglage est un artefact — il doit se dire');
final taille = tester.getSize(find.textContaining('Rangs à égalité'));
debugPrint('[appareil] note de rang : ${taille.width}×${taille.height} px');
}
// Descendre jusqu'aux charges : dans un `ListView`, ce qui n'est pas à l'écran n'est
// pas construit, et l'en-tête des rôles, la ligne de schéma puis la carte de réserve
// batterie occupent tout le haut. Sans ce défilement, la capture ne montre pas la
// section qu'on prétend regarder — et les cibles tactiles ne sont pas mesurables,
// faute d'être montées.
for (var pas = 0; pas < 25; pas++) {
if (find.text('Configurer').evaluate().length >= 2) break;
await tester.drag(find.byType(Scrollable).first, const Offset(0, -220));
await tester.pump(const Duration(milliseconds: 200));
}
expect(find.text('Configurer'), findsWidgets,
reason: 'les cartes de charge doivent être atteignables');
await _tenir(tester, 'liste des charges + note de rang');
// ── 3. Les cibles tactiles, mesurées à la taille d'écran RÉELLE ──────────
final poignees = find.byWidgetPredicate(
(w) => w is ReorderableDragStartListener, skipOffstage: true);
expect(poignees, findsWidgets, reason: 'les domaines doivent être saisissables');
var minPoignee = double.infinity;
for (var i = 0; i < poignees.evaluate().length; i++) {
final t = tester.getSize(poignees.at(i));
minPoignee = [minPoignee, t.width, t.height].reduce((a, b) => a < b ? a : b);
debugPrint('[appareil] poignée $i : ${t.width}×${t.height} px');
}
expect(minPoignee, greaterThanOrEqualTo(_cibleMini),
reason: 'la poignée de glissement est le seul geste sans repli de cet écran : '
'sous $_cibleMini px, un pouce la rate');
// Les flèches de rang — l'autre commande tactile de cet écran, et elle n'a pas de
// repli non plus : réordonner DANS un domaine ne se fait que par elles.
final fleches = find.byWidgetPredicate(
(w) => w is InkWell && w.borderRadius != null, skipOffstage: true);
var minFleche = double.infinity;
for (var i = 0; i < fleches.evaluate().length; i++) {
final t = tester.getSize(fleches.at(i));
if (t.width > 200) continue; // pas une flèche : une carte cliquable
minFleche = [minFleche, t.width, t.height].reduce((a, b) => a < b ? a : b);
}
if (minFleche.isFinite) {
debugPrint('[appareil] plus petite flèche de rang : $minFleche px');
expect(minFleche, greaterThanOrEqualTo(_cibleMini));
}
// ── 4. Le glissement AU DOIGT — geste réel, écriture abandonnée ──────────
//
// `tester.drag` pose le pointeur et le relâche en un coup ; une liste réordonnable
// exige un appui MAINTENU puis des mouvements successifs pour armer le glissement.
// Un `drag` simple « passe » sans rien réordonner — c'est ce qui avait fait croire à
// un geste avalé lors de l'enquête précédente.
final ordreAvant = config.groups.map((g) => g.domain).toList();
if (ordreAvant.length >= 2) {
// AMENER la première poignée dans l'écran, et le vérifier.
//
// Piège payé deux fois : `find` trouve un widget **posé mais hors du viewport**, et
// `getCenter` en rend alors une coordonnée qui n'existe pas à l'écran (mesuré :
// y = 1129 sur un écran de ${tester.view.physicalSize.height ~/ tester.view.devicePixelRatio} px de haut).
// Le geste part dans le vide et le symptôme est identique à un glissement ignoré.
// On positionne donc jusqu'à ce que la poignée soit RÉELLEMENT visible.
final hauteur =
tester.view.physicalSize.height / tester.view.devicePixelRatio;
for (var pas = 0; pas < 30; pas++) {
final y = tester.getCenter(poignees.at(0)).dy;
if (y > 140 && y < hauteur * 0.4) break;
await tester.drag(
find.byType(Scrollable).first, Offset(0, y > hauteur * 0.4 ? -160 : 160));
await tester.pump(const Duration(milliseconds: 150));
}
// La distance n'est PAS arbitraire : une liste réordonnable ne permute qu'une fois
// l'élément traîné passé le milieu du suivant. Un bloc de domaine porte ses cartes,
// donc il est haut — plusieurs centaines de pixels. Un déplacement « de quelques
// dizaines de pixels » ne franchit rien. On mesure donc l'écart réel entre deux
// poignées.
final p0 = tester.getCenter(poignees.at(0));
final p1 = tester.getCenter(poignees.at(1));
final course = (p1.dy - p0.dy) + 32;
debugPrint('[appareil] écran ${hauteur.round()} px · poignées à '
'${p0.dy.round()} et ${p1.dy.round()} · course ${course.round()} px');
expect(p0.dy, lessThan(hauteur),
reason: 'la poignée saisie doit être DANS l\'écran, pas seulement dans l\'arbre');
// Diagnostic : QUI prend le geste ? Si la page défile pendant le glissement, c'est
// le `ListView` extérieur qui a gagné l'arène — et le symptôme (« rien ne bouge »)
// est identique à celui d'une poignée qui n'accroche pas, pour une cause opposée.
double offsetPage() =>
tester.state<ScrollableState>(find.byType(Scrollable).first).position.pixels;
final avantOffset = offsetPage();
final geste = await tester.startGesture(p0);
await tester.pump(const Duration(milliseconds: 400)); // arme la prise
const pasDeGeste = 20;
for (var i = 0; i < pasDeGeste; i++) {
await geste.moveBy(Offset(0, course / pasDeGeste));
await tester.pump(const Duration(milliseconds: 40));
}
await tester.pump(const Duration(milliseconds: 300));
await geste.up();
await tester.pump(const Duration(seconds: 1));
final ordreApres = config.groups.map((g) => g.domain).toList();
debugPrint('[appareil] glissement : $ordreAvant → $ordreApres · '
'défilement de la page ${(offsetPage() - avantOffset).round()} px');
expect(ordreApres, isNot(ordreAvant),
reason: 'le glissement au doigt doit réordonner les domaines');
// Le banc n'appartient pas au test : rien n'est écrit.
config.discardDraft();
await tester.pump(const Duration(milliseconds: 400));
expect(config.hasPendingChanges, isFalse);
debugPrint('[appareil] brouillon abandonné — rien n\'est parti sur la box');
} else {
debugPrint('[appareil] GLISSEMENT NON EXERCÉ — un seul domaine sur ce banc');
}
// ── 5. L'écran de mécanisme d'une BORNE ──────────────────────────────────
//
// C'est la surface la plus neuve, et celle qui doit surtout NE PAS proposer ce
// qu'elle proposait : puissance nominale, paliers, et une bascule vers routeur de
// relais dont aucun écran ne sait revenir.
final borne = bornes.first;
Navigator.of(tester.element(find.byType(RolesDevicesScreen)))
.push(MaterialPageRoute(
builder: (_) => LoadMechanismScreen(loadId: borne.id)));
await tester.pump(const Duration(seconds: 2));
await _attendre(
tester,
() => find.byType(LoadMechanismScreen).evaluate().isNotEmpty,
quoi: 'le montage de l\'écran de mécanisme de « ${borne.label} »',
);
expect(find.textContaining('imposé · evcharger'), findsOneWidget,
reason: 'le mécanisme d\'une borne est imposé, pas au choix');
expect(find.text('Routeur de relais'), findsNothing,
reason: 'offrir la bascule serait un aller sans retour en deux gestes');
expect(find.text('SG-Ready'), findsNothing);
expect(find.textContaining('Borne'), findsWidgets);
await _tenir(tester, 'écran de mécanisme d\'une borne');
// ── 6. Verdict de mise en page ───────────────────────────────────────────
if (debordements.isNotEmpty) {
debugPrint('[appareil] DÉBORDEMENTS (${debordements.length}) :');
for (final d in debordements) {
debugPrint('[appareil] • $d');
}
} else {
debugPrint('[appareil] aucun débordement de mise en page');
}
expect(debordements, isEmpty,
reason: 'un texte qui déborde se voit sur le téléphone et nulle part ailleurs');
installateur.lock();
await tester.pump(const Duration(milliseconds: 500));
});
}