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
206 lines
8.6 KiB
Dart
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]);
|
|
});
|
|
}
|