etm-powersync-app/test/reorder_drag_test.dart
Patrick Schurig cd76df3a73 feat(etm16): s'aligner sur le nouveau contrat — table de paliers, stagesW, compteur et sonde
RELAIS : mon avertissement de reconstruction sur un réordonnancement est devenu FAUX, et
il est corrigé. `+etm16` compare la TABLE que les relais produisent (deriveStages), plus la
liste par index. Le miroir Dart suit, y compris le détail qui compte : les ThingIds d'une
combinaison se comparent comme un ENSEMBLE — collectés dans l'ordre de déclaration, fermer
{K1,K2} ou {K2,K1} est le même geste électrique. Mais l'appariement palier par palier
reste positionnel, ce qui préserve le seul cas où l'ordre signifie encore quelque chose :
deux relais de MÊME puissance, où il décide quel contact sert ce palier.

Conséquence pour l'écran : réordonner des contacteurs de puissances distinctes n'avertit
plus de rien. Avertir sur un geste devenu gratuit serait aussi trompeur que de se taire sur
un geste coûteux.

PALIERS : l'app ne dérive plus la combinatoire pour l'affichage — la box les publie
(mechanism.stagesW). Vérifié sur appareil : [0, 500, 1000, 1500, 2000, 2500, 3000, 3500].
deriveStages reste côté app pour la seule annonce d'impact, qui se fait avant l'écriture.
Tant que le brouillon n'a pas touché aux contacteurs, c'est la table PUBLIÉE qui s'affiche ;
dès qu'il y touche elle devient périmée, et l'écran montre une prévision annoncée comme
telle plutôt qu'une table qui décrit l'état d'avant.

COMPTEUR ET SONDE : ils ne « vont pas arriver », ils SONT au schéma de .75
(o:meterThingId, o:sensorThingId). Le grisage dérivé les a donc activés seul, sans nouvelle
version de l'app — exactement ce qui était promis. Mais un bloc actif sur un placeholder
mort est pire qu'un bloc grisé : les deux sont désormais câblés sur un vrai sélecteur,
filtré par INTERFACE (smartmeter, temperaturesensor) et jamais par nom de plugin.

Ni l'un ni l'autre n'entre dans sameHardware() : les rattacher ne reconstruit rien. Ni dans
le contrôle d'unicité : deux charges peuvent partager une sonde, un compteur divisionnaire
en couvrir plusieurs — le sélecteur ne grise donc rien ici, contrairement aux contacteurs.
Et la mesure sert à VÉRIFIER, jamais à décider : elle n'entre pas dans le budget, la charge
étant déjà comptée dans le racine dont elle n'est qu'une décomposition.

COMPTEURS CUMULÉS : la base purgée, les valeurs sont saines (55 730 / 50 / 6 327 / 62 007
kWh) et la tuile « compteur box invalide » a disparu d'elle-même. Le plafond de
plausibilité reste : un zéro réel s'affiche « 0,0 kWh », jamais « — ».

hasNeverRun était déjà traité comme un état distinct — « l'absence de cycle est dite pour
elle-même, jamais rendue par un âge de zéro ».

Vérifié sur appareil contre .75 : compteur racine relu, « à configurer » vide, deux bornes
listées (Simulated wallbox + Terra AC Charger), stagesW de la box, les deux champs
writable, 5/5 sections de l'écran SG-Ready. Tests 98/98, analyze 0 erreur.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff
2026-08-26 10:54:16 +02:00

206 lines
8.6 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-domaine-ecs', '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-domaine-ecs', '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.
expect(find.byKey(const ValueKey('poignee-domaine-ecs')), findsOneWidget);
});
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]);
});
}