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
313 lines
16 KiB
Dart
313 lines
16 KiB
Dart
/// 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));
|
||
});
|
||
}
|