etm-powersync-app/integration_test/ui_3g2_on_device_test.dart
Patrick Schurig 33d89a8c26 fix(ev): la réserve THING_MISSING est levée par la mesure, et la carte véhicule part
Deux gestes que le relevé du banc a tranchés, dans les deux sens.

1. THING_MISSING — MESURÉ, la réserve tombe

GetLoadTelemetry sur .75, 2026-08-28 10:29 UTC : la Terra AC supprimée est publiée
available=false · faultCode=THING_MISSING · decision=LOAD_UNAVAILABLE{frozenW:0} ·
mechanism.kind=evcharger. Le code SORT. Le test de rendu ne repose plus sur une trame
composée mais sur cette capture (test/fixtures/loadtelemetry_hems75_thingmissing.json).

Le masquage relevé mardi (etmvariableloadadapter.cpp:148-150, m_faulted testé avant que
THING_MISSING ne puisse s'exprimer) concerne l'adaptateur des charges VARIABLES — relais
et SG-Ready. L'adaptateur evcharger n'a pas ce chemin, d'où la différence. Il n'est donc
pas corrigé, seulement contourné par le hasard du chemin de code : remonté au brief
moteur avec la trame, parce que les deux codes envoient le client faire deux gestes
différents — « écriture échouée » fait démonter un coffret, « appareil absent » fait
rouvrir nymea.

2. EVChargingCard — elle part, et la monter aurait publié de la fiction

Trois des grandeurs qu'elle affiche ne sont JAMAIS lues sur la box : chargingMode,
chargingPower (3,6 kW) et solarSourcePercent (82 %) sont des valeurs par défaut de
EnergyData — _updateEnergyData ne les touche pas, et le mode n'est écrit qu'en optimiste
après une commande, jamais relu par GetChargingInfos. Sa feuille « Paramètres de charge »
proposait en plus un courant minimum, qui n'existe dans aucun contrat, et son bouton
« Enregistrer » ne faisait que Navigator.pop : rien n'était écrit. Ce n'était pas du code
mort à ranimer, c'était un écran à réécrire depuis le modèle.

⚠️ Et la surface vivante est pire que la morte : _EVWidget (favorites_screen.dart:419)
affiche les MÊMES valeurs par défaut comme si elles étaient mesurées — « 3600 W »,
« 82 % solaire », « Mode PV » — et écrit le mode sur des pastilles de 7 à 10 px sans
restituer un refus. Entrée 🔴 au TODO, avec ce qu'une carte de recharge peut dire
aujourd'hui : le mode lu par GetChargingInfos, la puissance mesurée du Thing, l'échéance
comme intention saisie. Ni SOC (n'existe pas), ni chargeEnergy (remis à zéro à chaque
interruption), ni courant minimum, ni phaseCount.

146 tests passent. flutter analyze : 27 remarques, deux de moins qu'avant — les deux
étaient dans le fichier supprimé.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f
2026-08-28 12:36:00 +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 = '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));
});
}