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
209 lines
8.6 KiB
Dart
209 lines
8.6 KiB
Dart
/// Le glisser-déposer du rang — **est-ce le geste, ou l'imbrication ?**
|
|
///
|
|
/// Constaté sur appareil le 2026-08-25 : déplacer une charge ne produit rien. Deux causes
|
|
/// possibles donnent exactement le même symptôme, et il faut les séparer avant de corriger
|
|
/// quoi que ce soit :
|
|
///
|
|
/// 1. **il n'y avait rien à déplacer** — sur `.75` chaque domaine ne contient qu'une seule
|
|
/// charge, et glisser dans une liste d'un élément ne peut rien produire ;
|
|
/// 2. **le geste est avalé** — la section vit dans le `ListView` de l'écran, et un
|
|
/// `ReorderableListView` imbriqué dans un scrollable peut perdre le geste au profit du
|
|
/// parent.
|
|
///
|
|
/// D'où deux montages : la section **seule**, puis la section **dans un `ListView`** comme
|
|
/// l'écran réel.
|
|
///
|
|
/// **Verdict du 2026-08-26 : ni l'un ni l'autre.** Les deux montages glissent
|
|
/// identiquement — le `ListView` parent n'avale rien. La cause du symptôme était bien que
|
|
/// le banc n'a **qu'une charge par domaine**.
|
|
///
|
|
/// Mais l'enquête a trouvé un **second défaut, réel celui-là** : 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 qui rend la zone entière préhensible — d'où le test qui saisit
|
|
/// délibérément le **centre du bloc**, pas l'icône.
|
|
library;
|
|
|
|
import 'package:etm_powersync_app/l10n/app_localizations.dart';
|
|
import 'package:etm_powersync_app/models/load_config_entry.dart';
|
|
import 'package:etm_powersync_app/providers/load_config_provider.dart';
|
|
import 'package:etm_powersync_app/providers/load_telemetry_provider.dart';
|
|
import 'package:etm_powersync_app/services/load_priority.dart';
|
|
import 'package:etm_powersync_app/services/nymea_service.dart';
|
|
import 'package:etm_powersync_app/widgets/controllable_loads_section.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
/// Deux charges **dans le MÊME domaine** — la seule configuration où un glissement a un
|
|
/// sens. C'est précisément ce que le banc n'a pas.
|
|
List<Map<String, dynamic>> _deuxDansUnDomaine() => [
|
|
{
|
|
'adapter': 'relay-router',
|
|
'domain': 'ecs',
|
|
'enabled': true,
|
|
'id': 'chauffe-eau',
|
|
'label': 'chauffe-eau',
|
|
'maxPowerW': 0,
|
|
'minOffS': 60,
|
|
'minOnS': 60,
|
|
'mode': 'fixed',
|
|
'needs': {'dailyDeadline': '', 'minEnergyWhPerDay': 0},
|
|
'powerLevels': <dynamic>[],
|
|
'priority': 1,
|
|
'relays': [
|
|
{'powerW': 2000, 'thingId': '{b033b212-1adb-4df0-ba2b-8fa477de52a2}'}
|
|
],
|
|
'sgReady': <String, dynamic>{},
|
|
},
|
|
{
|
|
'adapter': 'relay-router',
|
|
'domain': 'ecs',
|
|
'enabled': true,
|
|
'id': 'seche-serviettes',
|
|
'label': 'Sèche-serviettes',
|
|
'maxPowerW': 0,
|
|
'minOffS': 60,
|
|
'minOnS': 60,
|
|
'mode': 'fixed',
|
|
'needs': {'dailyDeadline': '', 'minEnergyWhPerDay': 0},
|
|
'powerLevels': <dynamic>[],
|
|
'priority': 2,
|
|
'relays': [
|
|
{'powerW': 500, 'thingId': '{8538782f-2c8c-4a30-bfce-c8140f791c9b}'}
|
|
],
|
|
'sgReady': <String, dynamic>{},
|
|
},
|
|
];
|
|
|
|
Widget _montage({
|
|
required LoadConfigProvider config,
|
|
required bool dansUnListView,
|
|
}) {
|
|
final section = ControllableLoadsSection(onConfigure: (_, __) {});
|
|
return MultiProvider(
|
|
providers: [
|
|
ChangeNotifierProvider.value(value: config),
|
|
ChangeNotifierProvider.value(value: LoadTelemetryProvider()),
|
|
ChangeNotifierProvider.value(value: NymeaService()),
|
|
],
|
|
child: MaterialApp(
|
|
locale: const Locale('fr'),
|
|
localizationsDelegates: L10n.localizationsDelegates,
|
|
supportedLocales: L10n.supportedLocales,
|
|
home: Scaffold(
|
|
body: dansUnListView
|
|
// Reproduit l'écran réel : la section est un enfant du ListView de
|
|
// « Rôles & appareils ».
|
|
? ListView(children: [section])
|
|
: SingleChildScrollView(child: section),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Ordre des charges du premier domaine, tel que la vue le porte.
|
|
List<String> _ordre(LoadConfigProvider p) =>
|
|
[for (final g in p.groups) ...g.loads.map((e) => e.id)];
|
|
|
|
/// Glisse une poignée désignée par sa clé, par-dessus la carte [parDessus].
|
|
///
|
|
/// La distance se CALCULE depuis la hauteur réelle de la carte visée : une valeur en dur
|
|
/// devient fausse dès qu'on ajoute une ligne à la carte, et le test échoue alors pour une
|
|
/// raison qui n'a rien à voir avec le geste. C'est exactement ce qui est arrivé.
|
|
Future<void> _glisserCle(
|
|
WidgetTester tester, String cle, String parDessus) async {
|
|
final poignee = find.byKey(ValueKey(cle));
|
|
expect(poignee, findsOneWidget, reason: 'poignée « $cle » introuvable');
|
|
final cible = find.byKey(ValueKey(parDessus));
|
|
expect(cible, findsOneWidget, reason: 'cible « $parDessus » introuvable');
|
|
final dy = tester.getSize(cible).height * 2;
|
|
final g = await tester.startGesture(tester.getCenter(poignee));
|
|
// ReorderableDragStartListener accepte le geste immédiatement : pas d'appui long.
|
|
await tester.pump(const Duration(milliseconds: 40));
|
|
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.pumpAndSettle();
|
|
}
|
|
|
|
/// La configuration RÉELLE du banc : une charge par domaine. C'est là que le glissement
|
|
/// d'une carte ne peut rien produire — et que celui des domaines doit prendre le relais.
|
|
List<Map<String, dynamic>> _uneParDomaine() {
|
|
final l = _deuxDansUnDomaine();
|
|
l[1] = {...l[1], 'domain': 'hvac', 'id': 'pac-terrain', 'label': 'PAC banc'};
|
|
return l;
|
|
}
|
|
|
|
void main() {
|
|
testWidgets('section SEULE — glisser la 1re charge sous la 2e réordonne',
|
|
(tester) async {
|
|
final config = LoadConfigProvider()..adoptForTest(_deuxDansUnDomaine());
|
|
expect(_ordre(config), ['chauffe-eau', 'seche-serviettes']);
|
|
|
|
await tester.pumpWidget(_montage(config: config, dansUnListView: false));
|
|
await tester.pumpAndSettle();
|
|
|
|
await _glisserCle(tester, 'poignee-charge-chauffe-eau', 'load-seche-serviettes');
|
|
|
|
expect(_ordre(config), ['seche-serviettes', 'chauffe-eau'],
|
|
reason: 'le glissement doit produire un nouvel ordre');
|
|
expect(config.hasPendingChanges, isTrue);
|
|
});
|
|
|
|
testWidgets('section DANS UN LISTVIEW — le geste survit-il à l\'imbrication ?',
|
|
(tester) async {
|
|
final config = LoadConfigProvider()..adoptForTest(_deuxDansUnDomaine());
|
|
|
|
await tester.pumpWidget(_montage(config: config, dansUnListView: true));
|
|
await tester.pumpAndSettle();
|
|
|
|
await _glisserCle(tester, 'poignee-charge-chauffe-eau', 'load-seche-serviettes');
|
|
|
|
expect(_ordre(config), ['seche-serviettes', 'chauffe-eau'],
|
|
reason: 'si CE test échoue seul, le ListView parent avale le geste');
|
|
});
|
|
|
|
testWidgets('une charge par domaine — la poignée n\'agrippe pas, et le dit',
|
|
(tester) async {
|
|
// Le cas du banc, et celui qui donnait l'impression que « rien ne fonctionne » : il
|
|
// n'y a rien à réordonner à l'intérieur d'un domaine d'une seule charge.
|
|
final config = LoadConfigProvider()..adoptForTest(_uneParDomaine());
|
|
expect(_ordre(config), ['chauffe-eau', 'pac-terrain']);
|
|
|
|
await tester.pumpWidget(_montage(config: config, dansUnListView: true));
|
|
await tester.pumpAndSettle();
|
|
|
|
// La poignée existe — le rang reste lisible — mais elle n'offre AUCUNE prise.
|
|
for (final id in ['chauffe-eau', 'pac-terrain']) {
|
|
final poignee = find.byKey(ValueKey('poignee-charge-$id'));
|
|
expect(poignee, findsOneWidget);
|
|
expect(
|
|
find.descendant(
|
|
of: poignee, matching: find.byType(ReorderableDragStartListener)),
|
|
findsNothing,
|
|
reason: 'seule dans son domaine, la charge ne doit pas faire croire '
|
|
'qu\'on peut la déplacer',
|
|
);
|
|
}
|
|
|
|
// Et l'ordre se change bien par les flèches de domaine.
|
|
config.reorderDomains(0, 2);
|
|
expect(_ordre(config), ['pac-terrain', 'chauffe-eau']);
|
|
});
|
|
|
|
test('le rang envoyé à la box se renumérote depuis la POSITION', () {
|
|
final config = LoadConfigProvider()..adoptForTest(_deuxDansUnDomaine());
|
|
config.reorderWithinDomain('ecs', 0, 2); // 1re → après la 2e
|
|
|
|
final payload = flattenToPayload(config.groups);
|
|
expect([for (final m in payload) m['id']],
|
|
['seche-serviettes', 'chauffe-eau']);
|
|
// C'est bien la POSITION qui fait le rang, pas la valeur lue sur la box.
|
|
expect([for (final m in payload) m['priority']], [1, 2]);
|
|
});
|
|
}
|