etm-powersync-app/integration_test/ui_3g2_on_device_test.dart
Patrick Schurig 01fdad73c5 fix(ergonomie): les cibles tactiles étaient à 26 px — trouvé au premier passage sur appareil
Le test d'appareil passe. Il a trouvé deux défauts réels que 129 tests unitaires ne
pouvaient pas voir, parce qu'aucun ne rend ces widgets à une taille d'écran réelle.

1. LA POIGNÉE DE GLISSEMENT MESURAIT 26 × 27 px, pour un minimum Material de 48. Le
   Container transparent posé lors de l'enquête précédente avait supprimé la bande morte
   entre l'icône et le reste, mais il n'avait jamais AGRANDI la cible : le geste accrochait
   quand on visait juste et ratait sinon. C'est le seul geste de cet écran sans repli —
   sans lui, l'ordre des domaines ne se change pas du tout.

2. LES FLÈCHES DE RANG MESURAIENT 18 × 18 px, la taille de l'icône, sans aucune marge de
   touche. Elles n'ont pas de repli non plus : réordonner DANS un domaine ne se fait que
   par elles.

Portées à 48 et 44. Et la capture d'appareil a montré le coût de la correction naïve :
empilées, deux cibles de 44 font 88 px de haut pour deux icônes de 18, et la colonne de
rang devient une colonne vide. Côte à côte, la hauteur retombe à 44 et la largeur ne coûte
rien au libellé. La cible tactile est la contrainte ; la disposition ne l'est pas.

Le harnais a lui-même demandé trois corrections avant de dire la vérité, et chacune est un
piège qui se reproduira :

- il regardait le HAUT de l'écran. Dans un ListView, ce qui n'est pas visible n'est pas
  construit : les cibles n'étaient pas mesurables faute d'être montées.
- `find` trouve un widget POSÉ MAIS HORS VIEWPORT, et getCenter en rend une coordonnée qui
  n'existe pas — mesuré y=1129 sur un écran de 825 px. Le geste partait dans le vide, avec
  un symptôme identique à un glissement ignoré.
- la course était devinée. Une liste réordonnable ne permute qu'une fois passé le milieu du
  suivant, et un bloc de domaine porte ses cartes : 384 px mesurés, contre les ~150 devinés.
  Elle se calcule maintenant depuis l'écart réel entre deux poignées.

Le test mesure aussi le défilement de la page pendant le glissement : si le ListView
extérieur gagnait l'arène, le symptôme serait le même que celui d'une poignée qui
n'accroche pas, pour une cause opposée. Mesuré à 0 px — c'est bien la poignée qui prend.

Aucun débordement de mise en page sur cet écran. Deux captures d'appareil jointes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016vrTVifar2GN5rtUh89Wkq
2026-08-27 15:25:23 +02:00

311 lines
15 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` **n'est montée par aucun écran**
/// (constaté le 2026-08-27 — le tableau de bord utilise `features/dashboard/widgets/`).
/// Il n'y a donc rien à regarder, et prétendre l'avoir vue serait faux.
///
/// 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 = '192.168.1.75';
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));
});
}