Le moteur a tranché l'entrelacement, et son argument va plus loin que le mien : grouper à l'affichage oui, à l'écriture non. Un regroupement appliqué à l'enregistrement est une machine qui réordonne ce qu'un humain a décidé — exactement ce que LM-1209 interdit au moteur. Et la contiguïté n'a jamais été un invariant : le waterfall trie sur le rang seul, `domain` n'est pas lu par l'arbitre. Il ne suffisait donc pas d'adoucir la note : c'est le modèle d'affichage qui la produisait. Un groupe était un SEAU par domaine, rassemblant les charges où qu'elles soient dans l'ordre — donc l'écran ne pouvait pas représenter un ordre entrelacé, et comme l'enregistrement réaplatit ce qui est affiché, TOUT enregistrement regroupait. La PAC reculait du rang 2 au rang 5 sans que personne l'ait demandé. Un groupe est maintenant une PLAGE CONTIGUË. L'affichage épouse l'ordre réel — deux plages `ev` séparées par `heating` s'affichent comme deux blocs — et réaplatir rend exactement ce qui a été lu. Test à l'appui : la PAC garde sa position, et l'ordre à plat écrit est identique à celui lu sur la box. Le regroupement reste possible, mais devient un GESTE : bouton « Regrouper par domaine », qui pose un brouillon — visible, annulable, rien ne part avant save(). Ce que l'enregistrement faisait en douce, quelqu'un le demande désormais. Et la note cesse d'alerter : l'ordre non groupé est une information, pas un défaut. L'annoncer comme une anomalie poussait à réparer un ordre que personne n'avait cassé — d'autant que depuis +etm27 une borne détectée entre en queue, ce qui produit un ordre non groupé sur une installation parfaitement saine. Trois conséquences trouvées en chemin, toutes dues au fait qu'un domaine peut désormais occuper plusieurs plages : · la clé de la liste réordonnable portait le DOMAINE → deux plages d'un même domaine partageaient une clé, et la liste cassait sur une assertion de sémantiques, loin de la cause. Elle porte l'id de la première charge de la plage. · le repli suivait le domaine → deux plages se repliaient ensemble. · reorderWithinDomain(domain) désignait la première plage venue → reorderWithinGroup(index). · assignDomain rejoint la DERNIÈRE plage du domaine, pas la première : remonter la charge dans la première la ferait sauter des rangs que personne n'a touchés. Retiré au passage : sansRankOriginAuto(). L'écho verbatim inchangé passe — mesuré côté moteur sur marque fraîche — donc la marque survit désormais aux gestes qui ne la concernent pas, et le badge ne s'éteint plus sur une borne que personne n'a classée. Noté au TODO : supprimer l'entrée d'une borne dont le Thing existe encore ne la recrée pas (défaut moteur en cours de correction). Le bouton réservé aux THING_MISSING protège déjà de ce cas — ne pas l'élargir. 226 tests, flutter analyze inchangé à 27 remarques. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f
208 lines
8.7 KiB
Dart
208 lines
8.7 KiB
Dart
/// Le réordonnancement du rang — **où doit vivre la prise ?**
|
|
///
|
|
/// Constaté sur appareil : déplacer une charge ne produisait rien. L'enquête a donné trois
|
|
/// résultats, et le dernier a fait revenir sur un choix de conception.
|
|
///
|
|
/// 1. **Le geste n'est pas avalé** par le `ListView` parent : section seule et section
|
|
/// imbriquée se comportent identiquement.
|
|
/// 2. **Le symptôme venait du banc** : `.75` n'a qu'une charge par domaine, et glisser dans
|
|
/// une liste d'un élément ne peut rien produire.
|
|
/// 3. **Le niveau glissable était le mauvais.** Sur une installation ordinaire, chaque
|
|
/// domaine porte une charge : l'ordre de service se règle donc *entre domaines*, et
|
|
/// c'est là que la prise doit être. Rendre les deux niveaux glissables est impossible —
|
|
/// deux `ReorderableListView` imbriqués se disputent le geste, et l'extérieur gagne.
|
|
/// Le glissement appartient donc au **domaine** ; à l'intérieur, deux charges se
|
|
/// réordonnent par des **flèches**.
|
|
///
|
|
/// Un défaut réel a été trouvé au passage et corrigé : le centre de la poignée tombait dans
|
|
/// le vide séparant l'icône du reste, et `ReorderableDragStartListener` défère le test de
|
|
/// collision à ses enfants — bande morte invisible. D'où le `Container` transparent, et
|
|
/// d'où le test qui saisit délibérément le **centre du bloc**.
|
|
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('glisser un DOMAINE réordonne le service — section seule',
|
|
(tester) async {
|
|
final config = LoadConfigProvider()..adoptForTest(_uneParDomaine());
|
|
expect(_ordre(config), ['chauffe-eau', 'pac-terrain']);
|
|
|
|
await tester.pumpWidget(_montage(config: config, dansUnListView: false));
|
|
await tester.pumpAndSettle();
|
|
|
|
await _glisserCle(tester, 'poignee-plage-chauffe-eau', 'load-pac-terrain');
|
|
|
|
expect(_ordre(config), ['pac-terrain', 'chauffe-eau'],
|
|
reason: 'le glissement du domaine doit produire un nouvel ordre');
|
|
expect(config.hasPendingChanges, isTrue);
|
|
});
|
|
|
|
testWidgets('le geste survit à l\'imbrication dans le ListView de l\'écran',
|
|
(tester) async {
|
|
final config = LoadConfigProvider()..adoptForTest(_uneParDomaine());
|
|
|
|
await tester.pumpWidget(_montage(config: config, dansUnListView: true));
|
|
await tester.pumpAndSettle();
|
|
|
|
await _glisserCle(tester, 'poignee-plage-chauffe-eau', 'load-pac-terrain');
|
|
|
|
expect(_ordre(config), ['pac-terrain', 'chauffe-eau'],
|
|
reason: 'si CE test échoue seul, le ListView parent avale le geste');
|
|
});
|
|
|
|
testWidgets('deux charges dans un domaine — les FLÈCHES les réordonnent',
|
|
(tester) async {
|
|
final config = LoadConfigProvider()..adoptForTest(_deuxDansUnDomaine());
|
|
expect(_ordre(config), ['chauffe-eau', 'seche-serviettes']);
|
|
|
|
await tester.pumpWidget(_montage(config: config, dansUnListView: true));
|
|
await tester.pumpAndSettle();
|
|
|
|
// Descendre la première : c'est le geste interne au domaine.
|
|
final bas = find.byKey(const ValueKey('descendre-chauffe-eau'));
|
|
expect(bas, findsOneWidget);
|
|
await tester.tap(bas);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(_ordre(config), ['seche-serviettes', 'chauffe-eau']);
|
|
});
|
|
|
|
testWidgets('une charge seule dans son domaine n\'a pas de flèches',
|
|
(tester) async {
|
|
// Rien à réordonner à l'intérieur : ne pas offrir de commande qui ne mène nulle part.
|
|
final config = LoadConfigProvider()..adoptForTest(_uneParDomaine());
|
|
await tester.pumpWidget(_montage(config: config, dansUnListView: true));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.byKey(const ValueKey('descendre-chauffe-eau')), findsNothing);
|
|
expect(find.byKey(const ValueKey('monter-pac-terrain')), findsNothing);
|
|
// Mais le domaine, lui, se glisse.
|
|
// La poignée porte l'id de la première charge de la PLAGE, pas le domaine : deux
|
|
// plages d'un même domaine partageraient sinon la même clé.
|
|
expect(find.byKey(const ValueKey('poignee-plage-chauffe-eau')), findsOneWidget);
|
|
});
|
|
|
|
test('le rang envoyé à la box se renumérote depuis la POSITION', () {
|
|
final config = LoadConfigProvider()..adoptForTest(_deuxDansUnDomaine());
|
|
config.reorderWithinGroup(0, 0, 2); // 1re → après la 2e, dans la plage 0
|
|
|
|
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]);
|
|
});
|
|
}
|