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
This commit is contained in:
parent
708914861e
commit
cd76df3a73
@ -24,12 +24,15 @@
|
||||
library;
|
||||
|
||||
import 'package:etm_powersync_app/main.dart' as app;
|
||||
import 'package:etm_powersync_app/providers/energy_setup_provider.dart';
|
||||
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/load_telemetry_provider.dart';
|
||||
import 'package:etm_powersync_app/providers/schema_provider.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/nymea_service.dart';
|
||||
import 'package:etm_powersync_app/services/rpc_schema.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
@ -244,6 +247,47 @@ void main() {
|
||||
debugPrint('[appareil] brouillon abandonné — rien n\'est écrit sur la box');
|
||||
}
|
||||
|
||||
// ── 5-bis-bis. Le compteur racine est RELU, pas redemandé ───────────────
|
||||
//
|
||||
// Il vivait sur la box sans que l'app le lise jamais : le rôle repartait « à
|
||||
// configurer » à chaque démarrage. Une configuration jamais lue se présente comme une
|
||||
// configuration perdue — et pousse à refaire un geste inutile.
|
||||
final racine = await svc.getRootMeter();
|
||||
debugPrint('[appareil] compteur racine lu sur la box : ${racine ?? 'AUCUN'}');
|
||||
expect(racine, isNotNull,
|
||||
reason: '.75 déclare un compteur racine : l\'app doit pouvoir le lire');
|
||||
|
||||
final setup = Provider.of<EnergySetupProvider>(ctx, listen: false);
|
||||
await setup.syncRootMeter(svc);
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
expect(setup.toConfigureRoles.map((r) => r.name), isNot(contains('gridMeter')),
|
||||
reason: 'le compteur racine ne doit plus être réclamé une fois lu');
|
||||
// Et les CHARGES n'ont plus rien à faire dans ce décompte : elles viennent de
|
||||
// GetLoadConfig depuis la fusion.
|
||||
for (final charge in ['dhw', 'heatPump', 'battery', 'evCharger']) {
|
||||
expect(setup.toConfigureRoles.map((r) => r.name), isNot(contains(charge)),
|
||||
reason: '« $charge » est une charge : elle ne se configure plus par un rôle');
|
||||
}
|
||||
debugPrint('[appareil] « à configurer » : '
|
||||
'${setup.toConfigureRoles.map((r) => r.name).toList()}');
|
||||
|
||||
// ── 5-septies. Les bornes de recharge, toutes, et sans rang ─────────────
|
||||
//
|
||||
// Elles ne sont pas des LoadConfig : elles n'ont ni adapter, ni priority, ni domain,
|
||||
// et n'apparaissent dans aucun domaine. Sans leur section, une borne configurée serait
|
||||
// simplement invisible. La liste vient de l'interface nymea `evcharger`, donc toute
|
||||
// borne appairée y entre — y compris une seconde.
|
||||
final classesBorne = {
|
||||
for (final c in svc.thingClasses)
|
||||
if (c.interfaces.contains('evcharger')) c.id
|
||||
};
|
||||
final bornes = [
|
||||
for (final t in svc.things)
|
||||
if (classesBorne.contains(t.thingClassId)) t.name
|
||||
];
|
||||
debugPrint('[appareil] bornes vues : $bornes');
|
||||
expect(bornes, isNotEmpty, reason: '.75 en déclare au moins une');
|
||||
|
||||
// ── 5-ter. Le chip de domaine PORTE son libellé ─────────────────────────
|
||||
//
|
||||
// Il ne rendait qu'une étiquette dessinée : le domaine d'une charge était illisible
|
||||
@ -261,24 +305,76 @@ void main() {
|
||||
expect(texteChip.any((t) => t.trim().isNotEmpty), isTrue,
|
||||
reason: 'le chip doit porter un libellé, pas seulement une icône');
|
||||
|
||||
// ── 5-quinquies. Les paliers viennent de la BOX, pas d'un calcul de l'app ──
|
||||
//
|
||||
// `+etm16` publie `mechanism.stagesW`. Une seule fonction moteur sert la construction
|
||||
// du routeur, la comparaison matérielle et cette publication : l'app n'a plus de
|
||||
// combinatoire à refaire, donc plus de divergence possible.
|
||||
final live = Provider.of<LoadTelemetryProvider>(ctx, listen: false)
|
||||
.entryFor('chauffe-eau');
|
||||
final stages = live?.mechanism?.stagesW;
|
||||
debugPrint('[appareil] paliers publiés par la box : $stages');
|
||||
expect(stages, isNotNull,
|
||||
reason: '.75 est en +etm16 : stagesW doit être publié');
|
||||
expect(stages, [0, 500, 1000, 1500, 2000, 2500, 3000, 3500]);
|
||||
|
||||
// ── 5-sexies. Les deux champs de mesure sont ARRIVÉS au schéma ──────────
|
||||
//
|
||||
// Ils étaient grisés « pas encore supporté ». `+etm16` les porte : le grisage dérivé
|
||||
// du schéma les a donc activés SEUL, sans nouvelle version de l'app.
|
||||
for (final champ in ['meterThingId', 'sensorThingId']) {
|
||||
final sup = schema.schema.paramSupport(
|
||||
'NymeaEnergy.SetLoadConfig', ['loadConfigs', '[]', champ]);
|
||||
debugPrint('[appareil] $champ → $sup');
|
||||
expect(sup, FieldSupport.writable,
|
||||
reason: '.75 est en +etm16 : « $champ » doit être écrivable');
|
||||
}
|
||||
|
||||
// ── 5-quater. L'écran de mécanisme, et son sélecteur ACTIF ──────────────
|
||||
// Le bouton est sous la ligne de flottaison : il faut l'amener à l'écran avant de
|
||||
// taper, sinon le geste ne porte sur rien.
|
||||
final boutons = find.text('Configurer');
|
||||
await tester.ensureVisible(boutons.first);
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
await tester.tap(boutons.first);
|
||||
await tester.pump(const Duration(seconds: 2));
|
||||
await tester.pump(const Duration(milliseconds: 600));
|
||||
await _attendre(tester, () => find.text('Mécanisme').evaluate().isNotEmpty,
|
||||
quoi: 'l\'ouverture de l\'écran de mécanisme');
|
||||
|
||||
for (final m in ['Routeur de relais', 'Modulable', 'SG-Ready']) {
|
||||
expect(find.text(m), findsWidgets,
|
||||
reason: 'le sélecteur de mécanisme doit proposer « $m »');
|
||||
}
|
||||
// Les sections que la maquette exige et qui manquaient.
|
||||
for (final titre in ['Relais commandés', 'Temporisations', 'Compteur dédié',
|
||||
'Arbitrage']) {
|
||||
expect(find.text(titre), findsWidgets, reason: 'section « $titre » absente');
|
||||
// Les sections attendues DÉPENDENT du mécanisme ouvert — c'est tout l'objet de ces
|
||||
// écrans : ils ne se ressemblent pas. « Configurer » ouvre la première carte, et
|
||||
// l'ordre des domaines n'est pas fixe ; on regarde donc ce qui s'est ouvert.
|
||||
final estRelais = find.text('Relais commandés').evaluate().isNotEmpty ||
|
||||
find.text('Contacts SG-Ready').evaluate().isEmpty;
|
||||
final attendues = estRelais
|
||||
? <String>['Relais commandés', 'Temporisations', 'Compteur dédié',
|
||||
'Sonde de température', 'Arbitrage']
|
||||
: <String>['Contacts SG-Ready', 'Table d\'états', 'Compteur dédié',
|
||||
'Sonde de température', 'Arbitrage'];
|
||||
|
||||
final vues = <String>{};
|
||||
for (var pas = 0; pas < 30 && vues.length < attendues.length; pas++) {
|
||||
for (final t in attendues) {
|
||||
if (find.text(t).evaluate().isNotEmpty) vues.add(t);
|
||||
}
|
||||
if (vues.length == attendues.length) break;
|
||||
await tester.drag(
|
||||
find.byKey(const ValueKey('liste-mecanisme')), const Offset(0, -150));
|
||||
await tester.pump(const Duration(milliseconds: 250));
|
||||
}
|
||||
debugPrint('[appareil] écran de mécanisme (${estRelais ? "relais" : "SG-Ready"}) — '
|
||||
'sélecteur : Routeur de relais / Modulable / SG-Ready ; sections vues : '
|
||||
'${vues.join(' · ')} (${vues.length}/${attendues.length})');
|
||||
for (final t in attendues) {
|
||||
expect(vues, contains(t), reason: 'section « $t » jamais atteinte');
|
||||
}
|
||||
debugPrint('[appareil] écran de mécanisme : sélecteur et sections présents');
|
||||
|
||||
// On repart sans rien changer.
|
||||
Navigator.of(tester.element(find.text('Arbitrage'))).pop();
|
||||
Navigator.of(tester.element(find.text('Arbitrage').first)).pop();
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
|
||||
// ── 6. On laisse l'écran affiché ────────────────────────────────────────
|
||||
|
||||
@ -113,6 +113,16 @@ class LoadConfigEntry {
|
||||
return n is Map ? ((n['minEnergyWhPerDay'] as num?)?.toInt() ?? 0) : 0;
|
||||
}
|
||||
|
||||
/// Compteur dédié à CETTE charge, et sonde de la grandeur rendue. Vides = non rattachés.
|
||||
///
|
||||
/// Ni l'un ni l'autre n'est du **matériel de commande** : ils n'entrent pas dans
|
||||
/// `sameHardware()`, donc les rattacher ne reconstruit rien et ne réarme aucun verrou.
|
||||
/// Ils n'entrent pas non plus dans le contrôle d'unicité des Things — deux charges
|
||||
/// peuvent légitimement partager une sonde de pièce, et un compteur de tableau
|
||||
/// divisionnaire en couvrir plusieurs.
|
||||
String get meterThingId => raw['meterThingId'] as String? ?? '';
|
||||
String get sensorThingId => raw['sensorThingId'] as String? ?? '';
|
||||
|
||||
/// Puissance nominale déclarée (mécanisme continu). **Saisie de l'installateur**,
|
||||
/// jamais une mesure : c'est une valeur de plaque.
|
||||
int get maxPowerW => (raw['maxPowerW'] as num?)?.toInt() ?? 0;
|
||||
@ -208,6 +218,8 @@ class LoadConfigEntry {
|
||||
int? minOffS,
|
||||
int? maxPowerW,
|
||||
String? mode,
|
||||
String? meterThingId,
|
||||
String? sensorThingId,
|
||||
List<Map<String, dynamic>>? relays,
|
||||
List<int>? powerLevels,
|
||||
Map<String, dynamic>? sgReady,
|
||||
@ -224,6 +236,8 @@ class LoadConfigEntry {
|
||||
'minOffS': ?minOffS,
|
||||
'maxPowerW': ?maxPowerW,
|
||||
'mode': ?mode,
|
||||
'meterThingId': ?meterThingId,
|
||||
'sensorThingId': ?sensorThingId,
|
||||
'relays': ?relays,
|
||||
'powerLevels': ?powerLevels,
|
||||
'sgReady': ?sgReady,
|
||||
|
||||
@ -130,6 +130,23 @@ class LoadMechanism {
|
||||
double? get stageW => _dn(raw['stageW']);
|
||||
double? get maxStageW => _dn(raw['maxStageW']);
|
||||
|
||||
/// Paliers atteignables, **publiés par la box** depuis `+etm16`.
|
||||
///
|
||||
/// Une seule fonction moteur (`RelayRouter::deriveStages`) sert la construction du
|
||||
/// routeur, la comparaison matérielle et cette publication : il n'y a donc plus de
|
||||
/// combinatoire à refaire côté app, ni de divergence possible.
|
||||
///
|
||||
/// **Elle est en télémétrie, pas dans `GetLoadConfig`** — précisément pour qu'elle ne
|
||||
/// parte jamais dans une écriture : ce sont les relais qui se déclarent, les paliers
|
||||
/// s'en déduisent.
|
||||
///
|
||||
/// `null` sur une box antérieure, ou sur un mécanisme qui n'en produit pas.
|
||||
List<int>? get stagesW {
|
||||
final v = raw['stagesW'];
|
||||
if (v is! List) return null;
|
||||
return [for (final x in v) if (x is num) x.toInt()];
|
||||
}
|
||||
|
||||
// variable
|
||||
double? get setpointW => _dn(raw['setpointW']);
|
||||
double? get percent => _dn(raw['percent']);
|
||||
|
||||
@ -255,9 +255,15 @@ class EnergySetupProvider extends ChangeNotifier {
|
||||
List<EmsRole> get controllableRoles => List.unmodifiable(_priorityOrder);
|
||||
|
||||
/// Rôles pickés encore non configurés → zone « À configurer ».
|
||||
/// Les rôles inférés n'y figurent jamais.
|
||||
List<EmsRole> get toConfigureRoles =>
|
||||
EmsRole.values.where((r) => r.isPicked && isUnconfigured(r)).toList();
|
||||
///
|
||||
/// **Les CHARGES en sont exclues depuis la fusion du lot C.** Elles ne viennent plus
|
||||
/// d'un rôle inféré des Things mais de `GetLoadConfig` : réclamer de configurer une
|
||||
/// « pompe à chaleur » pendant que la box en arbitre une, déclarée et vivante, est le
|
||||
/// défaut même que la fusion devait faire disparaître. Ce qui reste ici relève des
|
||||
/// **compteurs**, dont le contrat est ailleurs (`Energy.SetRootMeter`).
|
||||
List<EmsRole> get toConfigureRoles => EmsRole.values
|
||||
.where((r) => r.isPicked && !r.isControllableLoad && isUnconfigured(r))
|
||||
.toList();
|
||||
|
||||
/// Rôles actés « Pas d'appareil ».
|
||||
List<EmsRole> get absentRoles =>
|
||||
@ -265,13 +271,20 @@ class EnergySetupProvider extends ChangeNotifier {
|
||||
|
||||
// ── Compteur X/6 & complétude ──────────────────────────────────────────────
|
||||
|
||||
int get totalRoles => EmsRole.values.length;
|
||||
/// Rôles que cet écran gouverne encore — les **compteurs** seulement.
|
||||
///
|
||||
/// Compter les charges ici donnerait « 2 / 6 rôles configurés · 4 restent à configurer »
|
||||
/// sur une installation où les deux charges réelles sont déclarées et arbitrées. Le
|
||||
/// décompte doit décrire ce que l'écran demande, pas une énumération figée.
|
||||
int get totalRoles =>
|
||||
EmsRole.values.where((r) => !r.isControllableLoad).length;
|
||||
|
||||
/// Rôles configurés : inférés comptés d'office ; pickés comptés quand assignés
|
||||
/// **ou** actés absents.
|
||||
int get configuredCount {
|
||||
var n = 0;
|
||||
for (final r in EmsRole.values) {
|
||||
if (r.isControllableLoad) continue; // décomptées par LoadConfig, pas ici
|
||||
if (r.isInferred) {
|
||||
n++; // configurés d'office
|
||||
} else if (_states[r] is RolePresent || _states[r] is RoleAbsent) {
|
||||
@ -458,6 +471,21 @@ class EnergySetupProvider extends ChangeNotifier {
|
||||
/// Infère les rôles solaire & batterie depuis les interfaces des things : ils
|
||||
/// passent **direct** en zone définitive (badge « détecté »). N'écrase jamais
|
||||
/// un choix explicite (assignation manuelle ou « Pas d'appareil »).
|
||||
/// Adopte le compteur racine **que la box déclare**.
|
||||
///
|
||||
/// À appeler à la connexion. Sans elle, le rôle repart « à configurer » à chaque
|
||||
/// démarrage alors que la box a le compteur : une configuration jamais lue se présente
|
||||
/// exactement comme une configuration perdue, et pousse à refaire un geste inutile.
|
||||
Future<void> syncRootMeter(NymeaService service) async {
|
||||
final id = await service.getRootMeter();
|
||||
if (id == null) return;
|
||||
final actuel = _states[EmsRole.gridMeter];
|
||||
if (actuel is RolePresent && actuel.thingId == id) return;
|
||||
// `detected: true` : ce n'est pas une saisie de cette session, c'est un état lu.
|
||||
_states[EmsRole.gridMeter] = RolePresent(id, detected: true);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void syncFromThings(NymeaService service) {
|
||||
var changed = false;
|
||||
for (final role in const [EmsRole.solarMeter, EmsRole.battery]) {
|
||||
|
||||
@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
|
||||
import '../../l10n/l10n_ext.dart';
|
||||
import '../../models/load_config_entry.dart';
|
||||
import '../../providers/load_config_provider.dart';
|
||||
import '../../providers/load_telemetry_provider.dart';
|
||||
import '../../providers/schema_provider.dart';
|
||||
import '../../services/load_hardware.dart';
|
||||
import '../../services/load_mechanism_switch.dart';
|
||||
@ -59,6 +60,7 @@ class _LoadMechanismScreenState extends State<LoadMechanismScreen> {
|
||||
final provider = context.watch<LoadConfigProvider>();
|
||||
final schema = context.watch<SchemaProvider>();
|
||||
final service = context.watch<NymeaService>();
|
||||
final telemetry = context.watch<LoadTelemetryProvider>();
|
||||
final e = _entree(provider);
|
||||
|
||||
return Scaffold(
|
||||
@ -72,6 +74,7 @@ class _LoadMechanismScreenState extends State<LoadMechanismScreen> {
|
||||
body: e == null
|
||||
? const Center(child: Text('Charge introuvable dans la configuration.'))
|
||||
: ListView(
|
||||
key: const ValueKey('liste-mecanisme'),
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 32),
|
||||
children: [
|
||||
SchemaVersionBanner(
|
||||
@ -84,7 +87,11 @@ class _LoadMechanismScreenState extends State<LoadMechanismScreen> {
|
||||
_Mecanisme(entry: e, provider: provider),
|
||||
const SizedBox(height: 14),
|
||||
if (e.isRelayRouter)
|
||||
_SectionRelais(entry: e, provider: provider, service: service)
|
||||
_SectionRelais(
|
||||
entry: e,
|
||||
provider: provider,
|
||||
service: service,
|
||||
telemetry: telemetry)
|
||||
else if (e.isSgReady)
|
||||
_SectionSgReady(entry: e, provider: provider, service: service)
|
||||
else
|
||||
@ -128,7 +135,11 @@ class _LoadMechanismScreenState extends State<LoadMechanismScreen> {
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
_CompteurDedie(schema: schema.schema),
|
||||
_CompteurEtSonde(
|
||||
entry: e,
|
||||
provider: provider,
|
||||
schema: schema.schema,
|
||||
service: service),
|
||||
const SizedBox(height: 14),
|
||||
_Arbitrage(entry: e, provider: provider),
|
||||
const SizedBox(height: 18),
|
||||
@ -450,36 +461,35 @@ class _HeritageFixed extends StatelessWidget {
|
||||
|
||||
/// Les contacteurs, et **rien d'autre de matériel**.
|
||||
///
|
||||
/// Les paliers atteignables sont **déduits, jamais enregistrés** : la box les compose
|
||||
/// elle-même en essayant toutes les combinaisons de contacts. On les affiche parce qu'ils
|
||||
/// sont ce que l'installateur veut vérifier d'un coup d'œil — et on dit qu'ils sont
|
||||
/// déduits, pour que personne ne les cherche dans la configuration.
|
||||
/// Les paliers atteignables sont **publiés par la box** (`mechanism.stagesW`, depuis
|
||||
/// `+etm16`) et jamais enregistrés : ce sont les relais qui se déclarent, les paliers s'en
|
||||
/// déduisent. L'app les LIT au lieu de refaire la combinatoire — une seule fonction moteur
|
||||
/// sert la construction du routeur, la comparaison matérielle et cette publication, donc
|
||||
/// plus de divergence possible.
|
||||
///
|
||||
/// Tant que le brouillon n'a pas touché aux contacteurs, c'est cette table publiée qui
|
||||
/// s'affiche. Dès qu'il y touche, elle devient périmée : on montre alors une **prévision**,
|
||||
/// annoncée comme telle — mieux vaut dire « voici ce que ça donnera » que d'afficher une
|
||||
/// table qui décrit encore l'état d'avant.
|
||||
class _SectionRelais extends StatelessWidget {
|
||||
final LoadConfigEntry entry;
|
||||
final LoadConfigProvider provider;
|
||||
final NymeaService service;
|
||||
final LoadTelemetryProvider telemetry;
|
||||
|
||||
const _SectionRelais({
|
||||
required this.entry,
|
||||
required this.provider,
|
||||
required this.service,
|
||||
required this.telemetry,
|
||||
});
|
||||
|
||||
/// Toutes les puissances atteignables par combinaison de contacts, dédupliquées.
|
||||
///
|
||||
/// Miroir de ce que fait le `RelayRouter` : 2^N combinaisons, doublons écartés (deux
|
||||
/// relais de même puissance ne créent pas de palier supplémentaire), plafond 16 relais.
|
||||
static List<int> paliers(List<LoadRelay> relais) {
|
||||
if (relais.isEmpty || relais.length > 16) return const [];
|
||||
final vus = <int>{0};
|
||||
for (var masque = 1; masque < (1 << relais.length); masque++) {
|
||||
var somme = 0;
|
||||
for (var i = 0; i < relais.length; i++) {
|
||||
if (masque & (1 << i) != 0) somme += relais[i].powerW;
|
||||
}
|
||||
vus.add(somme);
|
||||
/// Vrai si le brouillon a touché aux contacteurs — la table publiée est alors périmée.
|
||||
bool get _relaisModifies {
|
||||
for (final e in provider.entries) {
|
||||
if (e.id == entry.id) return hardwareDiff(e, entry).contains('relays');
|
||||
}
|
||||
return vus.toList()..sort();
|
||||
return false;
|
||||
}
|
||||
|
||||
void _remplacer(List<Map<String, dynamic>> relais) =>
|
||||
@ -490,7 +500,11 @@ class _SectionRelais extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final p = paliers(entry.relays);
|
||||
// La table PUBLIÉE par la box fait foi. Elle décrit ce qui tourne — donc elle est
|
||||
// périmée dès que le brouillon touche aux contacteurs.
|
||||
final publiee = telemetry.entryFor(entry.id)?.mechanism?.stagesW;
|
||||
final aJour = publiee != null && !_relaisModifies;
|
||||
final p = aJour ? publiee : deriveStages(entry.relays).levels;
|
||||
final pris = {
|
||||
for (final r in entry.relays) normalizeThingId(r.thingId),
|
||||
};
|
||||
@ -550,7 +564,9 @@ class _SectionRelais extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'${entry.relays.length} relais → paliers atteignables :',
|
||||
aJour
|
||||
? '${entry.relays.length} relais → paliers publiés par la box :'
|
||||
: '${entry.relays.length} relais → paliers prévus :',
|
||||
style: TextStyle(
|
||||
fontSize: 11.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
@ -566,9 +582,11 @@ class _SectionRelais extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
Text(
|
||||
'Déduit, jamais enregistré. La box compose elle-même les paliers en '
|
||||
'essayant toutes les combinaisons de contacts ; deux relais de même '
|
||||
'puissance ne créent pas de palier supplémentaire.',
|
||||
aJour
|
||||
? 'Publiés par la box en télémétrie, jamais enregistrés : ce sont '
|
||||
'les contacteurs qui se déclarent, les paliers s\'en déduisent.'
|
||||
: 'PRÉVISION — les contacteurs ont changé dans ce brouillon. La box '
|
||||
'publiera sa propre table au premier cycle après enregistrement.',
|
||||
style: TextStyle(
|
||||
fontSize: 10.5,
|
||||
height: 1.35,
|
||||
@ -1285,58 +1303,115 @@ class _LigneDuree extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Compteur dédié — **prévu, pas encore supporté**, et l'écran le dit.
|
||||
/// Compteur dédié et sonde — deux champs de **même forme**, une même règle.
|
||||
///
|
||||
/// Le champ n'est pas grisé « au cas où » : il est grisé parce que le schéma de la box ne
|
||||
/// le porte pas, et le grisage se dérive de ce schéma. Inventer `meterThingId` ferait
|
||||
/// échouer l'enregistrement de **toutes** les charges — nymea rejette toute clé inconnue
|
||||
/// **avant** le handler, et le refus porte sur l'appel entier.
|
||||
class _CompteurDedie extends StatelessWidget {
|
||||
/// ## Ils ne sont ni du matériel, ni exclusifs
|
||||
///
|
||||
/// Rattacher un compteur ne reconstruit rien : ces deux champs n'entrent pas dans
|
||||
/// `sameHardware()`, donc la mise à jour est **en place**, verrous préservés. Ils n'entrent
|
||||
/// pas non plus dans le contrôle d'unicité des Things — deux charges peuvent légitimement
|
||||
/// partager une sonde de pièce, et un compteur de tableau divisionnaire en couvrir
|
||||
/// plusieurs. Le sélecteur ne grise donc **rien** ici, contrairement aux contacteurs.
|
||||
///
|
||||
/// ## Ce que le compteur autorise à conclure, et rien de plus
|
||||
///
|
||||
/// Il sert à **vérifier**, jamais à décider : commandé 3 000 W, mesuré 0 W sur plusieurs
|
||||
/// cycles, la charge ne fait pas ce qu'on lui demande. La mesure n'entre pas dans le
|
||||
/// budget — elle est en retard sur la commande, et la charge est déjà comptée dans le
|
||||
/// compteur racine dont elle n'est qu'une décomposition. L'ajouter compterait deux fois la
|
||||
/// même énergie.
|
||||
///
|
||||
/// ## Sélection par INTERFACE, jamais par plugin
|
||||
///
|
||||
/// Le compteur se choisit parmi les Things portant `smartmeter`, la sonde parmi celles qui
|
||||
/// portent une interface de température. Nommer un plugin rendrait le modèle faux au
|
||||
/// prochain qui rend le même service, et obligerait à tenir une table à la main.
|
||||
///
|
||||
/// ## Et si la box ne les porte pas
|
||||
///
|
||||
/// Le grisage vient du **schéma**, jamais d'une table de versions : sur une box antérieure
|
||||
/// le champ est absent, le bloc se grise et le dit. Sur `.75` en `+etm16` il est là — le
|
||||
/// réglage s'est donc activé **seul**, sans nouvelle version de l'app, exactement comme
|
||||
/// promis.
|
||||
class _CompteurEtSonde extends StatelessWidget {
|
||||
final LoadConfigEntry entry;
|
||||
final LoadConfigProvider provider;
|
||||
final RpcSchema schema;
|
||||
final NymeaService service;
|
||||
|
||||
const _CompteurDedie({required this.schema});
|
||||
const _CompteurEtSonde({
|
||||
required this.entry,
|
||||
required this.provider,
|
||||
required this.schema,
|
||||
required this.service,
|
||||
});
|
||||
|
||||
FieldSupport _support(String champ) => schema.paramSupport(
|
||||
'NymeaEnergy.SetLoadConfig', ['loadConfigs', '[]', champ]);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final support = schema.paramSupport(
|
||||
'NymeaEnergy.SetLoadConfig', ['loadConfigs', '[]', 'meterThingId']);
|
||||
return _Carte(
|
||||
titre: 'Compteur dédié',
|
||||
badge: 'prévu · pas encore supporté',
|
||||
sousTitre: 'Rattacher un compteur à cette charge.',
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SchemaGated(
|
||||
support: support,
|
||||
fieldName: 'meterThingId',
|
||||
label: 'Compteur dédié',
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 11),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: EtmTokens.lineOf(context)),
|
||||
),
|
||||
child: Row(children: [
|
||||
Expanded(
|
||||
child: Text('Choisir un appareil de mesure…',
|
||||
style: TextStyle(
|
||||
fontSize: 13.5, color: EtmTokens.mutedOf(context))),
|
||||
final supportCompteur = _support('meterThingId');
|
||||
final supportSonde = _support('sensorThingId');
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
_Carte(
|
||||
titre: 'Compteur dédié',
|
||||
badge: supportCompteur.isWritable
|
||||
? 'enregistré · meterThingId'
|
||||
: 'pas encore supporté',
|
||||
sousTitre: 'Mesure de CETTE charge — pour vérifier ce qui est commandé, '
|
||||
'jamais pour décider.',
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SchemaGated(
|
||||
support: supportCompteur,
|
||||
fieldName: 'meterThingId',
|
||||
label: 'Compteur dédié',
|
||||
child: ThingPicker(
|
||||
service: service,
|
||||
interfaceRequise: 'smartmeter',
|
||||
valeur: entry.meterThingId.isEmpty ? null : entry.meterThingId,
|
||||
placeholder: 'Choisir un appareil de mesure…',
|
||||
onChoisi: (id) => provider.replaceEntry(
|
||||
entry.id, entry.patched(meterThingId: id)),
|
||||
),
|
||||
Icon(Icons.expand_more_rounded,
|
||||
size: 20, color: EtmTokens.mutedOf(context)),
|
||||
]),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
_Aide(
|
||||
'La mesure n\'entre pas dans le budget : elle est en retard sur la '
|
||||
'commande, et cette charge est déjà comptée dans le compteur racine dont '
|
||||
'elle n\'est qu\'une décomposition. L\'y ajouter compterait deux fois la '
|
||||
'même énergie.',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
_Carte(
|
||||
titre: 'Sonde de température',
|
||||
badge: supportSonde.isWritable
|
||||
? 'enregistré · sensorThingId'
|
||||
: 'pas encore supporté',
|
||||
sousTitre: 'La grandeur physique du service rendu — ce que la charge produit, '
|
||||
'pas ce qu\'elle consomme.',
|
||||
child: SchemaGated(
|
||||
support: supportSonde,
|
||||
fieldName: 'sensorThingId',
|
||||
label: 'Sonde de température',
|
||||
child: ThingPicker(
|
||||
service: service,
|
||||
interfaceRequise: 'temperaturesensor',
|
||||
valeur: entry.sensorThingId.isEmpty ? null : entry.sensorThingId,
|
||||
placeholder: 'Choisir une sonde…',
|
||||
onChoisi: (id) => provider.replaceEntry(
|
||||
entry.id, entry.patched(sensorThingId: id)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
_Aide(
|
||||
'La box estime la consommation depuis les paliers déclarés, elle ne la '
|
||||
'mesure pas — c\'est ici que le besoin est le plus fort. Mais aucun champ '
|
||||
'n\'existe : en inventer un ferait rejeter l\'enregistrement de toutes les '
|
||||
'charges, pas seulement de celle-ci.',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -50,7 +50,11 @@ class _RolesDevicesScreenState extends State<RolesDevicesScreen> {
|
||||
if (!mounted) return;
|
||||
final svc = context.read<NymeaService>();
|
||||
_svc = svc;
|
||||
context.read<EnergySetupProvider>().attach(svc);
|
||||
context.read<EnergySetupProvider>()
|
||||
..attach(svc)
|
||||
// Le compteur racine vit sur la BOX : le relire, sinon il se présente comme
|
||||
// perdu à chaque démarrage.
|
||||
..syncRootMeter(svc);
|
||||
|
||||
// Chemin de données des CHARGES : la configuration de la box, et rien d'autre.
|
||||
context.read<LoadConfigProvider>()..attach(svc)..load();
|
||||
|
||||
@ -17,22 +17,27 @@
|
||||
/// 900 s, l'installateur qui corrige une ligne se retrouve avec une machine figée un quart
|
||||
/// d'heure — et rien ne le lui aura dit. D'où cet écran d'annonce.
|
||||
///
|
||||
/// ## Le piège mesuré sur `.75` le 2026-08-25
|
||||
/// ## Les relais se comparent par leur TABLE, pas par leur liste — depuis `+etm16`
|
||||
///
|
||||
/// La comparaison des relais est **indexée**, pas ensembliste : `relays[i].thingId` et
|
||||
/// `relays[i].powerW`, un à un. **Réordonner la liste sans rien changer d'autre est donc
|
||||
/// une reconstruction.** Vérifié sur le banc — `relays[]` inversé (2000/1000/500 au lieu
|
||||
/// de 500/1000/2000), même ensemble, mêmes Things, mêmes puissances :
|
||||
/// La comparaison était indexée : réordonner `relays[]` sans rien changer d'autre passait
|
||||
/// pour un changement matériel et coûtait une reconstruction complète. Signalé au moteur,
|
||||
/// **corrigé côté moteur** — `sameHardware()` compare désormais
|
||||
/// `RelayRouter::deriveStages(relays)`, c'est-à-dire la table de paliers que les relais
|
||||
/// PRODUISENT, et non la liste qui la décrit.
|
||||
///
|
||||
/// ```text
|
||||
/// [RelayRouter] chauffe-eau — reprise au démarrage : palier 0 W […]
|
||||
/// [EnergyArbitrator] relay-router construit depuis config: "chauffe-eau" ( 3 relais)
|
||||
/// [RelayRouter] "chauffe-eau" → […] (force) | "Charge désactivée — mise en état sûr […]"
|
||||
/// [EnergyArbitrator] 2 charge(s) active(s) — 1 créée(s), 1 inchangée(s), 1 retirée(s)
|
||||
/// ```
|
||||
/// Ce que ça change, et pourquoi un ensemble n'aurait pas suffi :
|
||||
///
|
||||
/// Un glisser-déposer dans la liste des contacteurs est donc un geste **matériel**. Il
|
||||
/// doit s'annoncer comme tel, et c'est le cas le moins intuitif de tous.
|
||||
/// - **réordonner des relais de puissances distinctes** ne change pas la table → aucune
|
||||
/// reconstruction. Vérifié au banc : le journal dit « 2 INCHANGÉE(S) », aucun état sûr,
|
||||
/// aucune commutation ;
|
||||
/// - **échanger deux relais de MÊME puissance** change le contact qui sert ce palier — à
|
||||
/// somme égale la table retient la première combinaison rencontrée. Autre contacteur qui
|
||||
/// s'use, autre résistance qui chauffe. Un ensemble ne le verrait pas ; la table, si.
|
||||
///
|
||||
/// [deriveStages] ci-dessous est le **miroir** de l'implémentation moteur. Elle existe
|
||||
/// parce que l'annonce d'impact se fait côté app, avant l'écriture — mais elle ne sert
|
||||
/// jamais à AFFICHER les paliers : la box les publie elle-même en télémétrie
|
||||
/// (`mechanism.stagesW`), et c'est cette valeur-là qui fait foi.
|
||||
library;
|
||||
|
||||
import '../models/load_config_entry.dart';
|
||||
@ -85,11 +90,49 @@ class LoadImpact {
|
||||
impact == LoadChangeImpact.rebuild || impact == LoadChangeImpact.removed;
|
||||
}
|
||||
|
||||
/// Table de paliers produite par une liste de relais — **miroir** de
|
||||
/// `RelayRouter::deriveStages()`.
|
||||
///
|
||||
/// Toutes les sommes de sous-ensembles, dédupliquées par puissance, triées, 0 inclus
|
||||
/// (sous-ensemble vide). À puissance égale on garde la **première** combinaison
|
||||
/// rencontrée : c'est ce qui rend l'ordre de déclaration significatif dans ce cas, et dans
|
||||
/// celui-là seulement.
|
||||
///
|
||||
/// \return les paliers et, pour chacun, les Things à fermer.
|
||||
({List<int> levels, List<List<String>> mapping}) deriveStages(
|
||||
List<LoadRelay> relays) {
|
||||
var n = relays.length;
|
||||
if (n > kMaxRelays) n = kMaxRelays;
|
||||
|
||||
final parPuissance = <int, List<String>>{};
|
||||
for (var masque = 0; masque < (1 << n); masque++) {
|
||||
var somme = 0;
|
||||
final ensemble = <String>[];
|
||||
for (var i = 0; i < n; i++) {
|
||||
if (masque & (1 << i) != 0) {
|
||||
somme += relays[i].powerW;
|
||||
ensemble.add(normalizeThingId(relays[i].thingId));
|
||||
}
|
||||
}
|
||||
parPuissance.putIfAbsent(somme, () => ensemble);
|
||||
}
|
||||
|
||||
final niveaux = parPuissance.keys.toList()..sort();
|
||||
return (
|
||||
levels: niveaux,
|
||||
mapping: [for (final k in niveaux) parPuissance[k]!],
|
||||
);
|
||||
}
|
||||
|
||||
/// Plafond du moteur : au-delà, la combinatoire n'est plus calculée.
|
||||
const int kMaxRelays = 16;
|
||||
|
||||
/// Miroir **exact** de `EnergyArbitrator::sameHardware()` (energyarbitrator.cpp).
|
||||
///
|
||||
/// Toute divergence ferait mentir l'annonce — dans un sens comme dans l'autre. Les champs
|
||||
/// comparés, dans l'ordre du moteur : `adapter`, `mode`, `minOnS`, `minOffS`, `maxPowerW`,
|
||||
/// `powerLevels`, la charge utile `sgReady` entière, puis `relays[]` **index par index**.
|
||||
/// `powerLevels`, la charge utile `sgReady` entière, puis la **table** que `relays[]`
|
||||
/// produit — [deriveStages], et non la liste elle-même.
|
||||
///
|
||||
/// \return vrai si le moteur gardera l'adaptateur en place.
|
||||
bool sameHardware(LoadConfigEntry a, LoadConfigEntry b) =>
|
||||
@ -111,19 +154,14 @@ List<String> hardwareDiff(LoadConfigEntry a, LoadConfigEntry b) {
|
||||
if (!_sameIntList(a.powerLevelsInt, b.powerLevelsInt)) diff.add('powerLevels');
|
||||
if (!_sameSgReady(a, b)) diff.add('sgReady');
|
||||
|
||||
// Comparaison INDEXÉE, comme le moteur : un réordonnancement compte.
|
||||
final ra = a.relays, rb = b.relays;
|
||||
var relaysDiffer = ra.length != rb.length;
|
||||
if (!relaysDiffer) {
|
||||
for (var i = 0; i < ra.length; i++) {
|
||||
if (normalizeThingId(ra[i].thingId) != normalizeThingId(rb[i].thingId) ||
|
||||
ra[i].powerW != rb[i].powerW) {
|
||||
relaysDiffer = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// On compare la TABLE que les relais produisent, comme le moteur depuis `+etm16` —
|
||||
// pas la liste qui la décrit.
|
||||
final ta = deriveStages(a.relays);
|
||||
final tb = deriveStages(b.relays);
|
||||
if (!_sameIntList(ta.levels, tb.levels) ||
|
||||
!_sameMapping(ta.mapping, tb.mapping)) {
|
||||
diff.add('relays');
|
||||
}
|
||||
if (relaysDiffer) diff.add('relays');
|
||||
|
||||
return diff;
|
||||
}
|
||||
@ -240,6 +278,26 @@ bool _sameSgReady(LoadConfigEntry a, LoadConfigEntry b) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Compare deux affectations palier → contacts, **position par position**, chaque
|
||||
/// combinaison étant comparée comme un **ENSEMBLE**.
|
||||
///
|
||||
/// Miroir de `RelayStageTable::operator==`. Les ThingIds d'une combinaison sont collectés
|
||||
/// dans l'ordre de déclaration des relais : fermer {K1,K2} et fermer {K2,K1} produit deux
|
||||
/// listes différentes pour un geste électrique identique. Les comparer comme des listes
|
||||
/// ferait reconstruire la charge sur un simple réordonnancement — le défaut même que cette
|
||||
/// table corrige.
|
||||
///
|
||||
/// L'ordre reste significatif là où il l'est vraiment : dans le **choix** de la combinaison
|
||||
/// retenue à somme égale, que `levels` et l'appariement position par position capturent.
|
||||
bool _sameMapping(List<List<String>> a, List<List<String>> b) {
|
||||
if (a.length != b.length) return false;
|
||||
for (var i = 0; i < a.length; i++) {
|
||||
if (a[i].length != b[i].length) return false;
|
||||
if (!a[i].toSet().containsAll(b[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool _sameStringList(List<String> a, List<String> b) {
|
||||
if (a.length != b.length) return false;
|
||||
for (var i = 0; i < a.length; i++) {
|
||||
|
||||
@ -1130,6 +1130,33 @@ class NymeaService extends ChangeNotifier {
|
||||
// CONFIG ÉNERGIE — rôles & charges pilotées (écran Rôles & appareils)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// `Energy.GetRootMeter` — le compteur principal **tel que la box le connaît**.
|
||||
///
|
||||
/// ## Ce qu'il répare
|
||||
///
|
||||
/// L'app ne l'appelait **jamais** : elle savait poser le compteur racine
|
||||
/// (`SetRootMeter`) mais pas le relire. Son état en mémoire repartait donc vide à chaque
|
||||
/// démarrage, et le rôle « compteur réseau » réapparaissait « à configurer » alors que la
|
||||
/// box l'avait bel et bien — vérifié sur `.75`, qui rend
|
||||
/// `549449c1-…` (Fronius Three Phase Meter) sans discontinuer.
|
||||
///
|
||||
/// Rien n'était perdu : c'était une configuration **jamais lue**. La nuance compte, parce
|
||||
/// qu'elle change le geste — il n'y a pas à reconfigurer, il y a à afficher.
|
||||
///
|
||||
/// \return le ThingId du compteur racine, ou `null` si la box n'en a pas.
|
||||
Future<String?> getRootMeter() async {
|
||||
if (_isSimulation || !_connected) return null;
|
||||
try {
|
||||
final r = await _sendRequest('Energy.GetRootMeter', {});
|
||||
final id = r['params']?['rootMeterThingId'] as String?;
|
||||
_log('[RootMeter] GetRootMeter → ${id ?? 'aucun'}', force: true);
|
||||
return (id == null || id.isEmpty) ? null : id;
|
||||
} catch (e) {
|
||||
_log('[RootMeter] GetRootMeter: $e', force: true);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// gridMeter → **réel** `Energy.SetRootMeter` (natif, testable contre `hems`).
|
||||
Future<void> setRootMeter(String thingId) async {
|
||||
if (_isSimulation || !_connected) {
|
||||
|
||||
@ -74,16 +74,22 @@ class ControllableLoadsSection extends StatelessWidget {
|
||||
// Le budget du cycle AVANT les charges : « Alloué 3 000 W » ne veut rien dire
|
||||
// tant qu'on ne sait pas s'il y avait 3 100 ou 12 000 W à distribuer.
|
||||
TelemetryHeader(telemetry: telemetry),
|
||||
// Les domaines se réordonnent par les FLÈCHES, pas au glisser-déposer.
|
||||
// Le niveau glissable est celui des DOMAINES, pas des cartes.
|
||||
//
|
||||
// Tentative faite et annulée : envelopper les domaines dans un second
|
||||
// `ReorderableListView` rend bien les domaines glissables, mais **casse le
|
||||
// glissement des cartes** — deux listes réordonnables imbriquées se disputent le
|
||||
// geste, et c'est l'extérieure qui gagne. Le test `reorder_drag_test.dart` le
|
||||
// montre en une exécution. On garde donc un seul niveau glissable, celui des
|
||||
// charges, et des flèches explicites pour les domaines.
|
||||
for (var i = 0; i < groups.length; i++)
|
||||
_DomainBlock(
|
||||
// Deux ReorderableListView imbriqués se disputent le geste — l'extérieur gagne et
|
||||
// l'intérieur devient inerte. Il faut donc choisir, et le choix se décide par ce
|
||||
// que l'installateur cherche à faire : sur une installation ordinaire, chaque
|
||||
// domaine porte UNE charge, et l'ordre de service se règle donc entre domaines.
|
||||
// C'est là que la prise doit être. Réordonner deux charges d'un même domaine reste
|
||||
// possible, par les flèches de leur carte.
|
||||
ReorderableListView.builder(
|
||||
shrinkWrap: true,
|
||||
primary: false,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
buildDefaultDragHandles: false,
|
||||
itemCount: groups.length,
|
||||
onReorder: provider.reorderDomains,
|
||||
itemBuilder: (context, i) => _DomainBlock(
|
||||
key: ValueKey('domain-${groups[i].domain}'),
|
||||
group: groups[i],
|
||||
index: i,
|
||||
@ -92,6 +98,7 @@ class ControllableLoadsSection extends StatelessWidget {
|
||||
telemetry: telemetry,
|
||||
onConfigure: onConfigure,
|
||||
),
|
||||
),
|
||||
// La borne n'est PAS une LoadConfig : elle n'apparaît donc dans aucun domaine
|
||||
// ci-dessus, et sans cette section elle serait tout simplement invisible — ce
|
||||
// qu'un installateur qui vient de la configurer lit comme une perte.
|
||||
@ -136,6 +143,15 @@ class _DomainBlock extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(children: [
|
||||
ReorderableDragStartListener(
|
||||
key: ValueKey('poignee-domaine-${group.domain}'),
|
||||
index: index,
|
||||
child: Container(
|
||||
color: Colors.transparent,
|
||||
padding: const EdgeInsets.only(right: 7, top: 4, bottom: 4),
|
||||
child: Icon(Icons.drag_indicator_rounded, size: 19, color: muted),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
domainLabel(t, group.domain),
|
||||
@ -164,26 +180,19 @@ class _DomainBlock extends StatelessWidget {
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 6),
|
||||
ReorderableListView.builder(
|
||||
shrinkWrap: true,
|
||||
primary: false,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
buildDefaultDragHandles: false,
|
||||
itemCount: group.loads.length,
|
||||
onReorder: (o, n) => provider.reorderWithinDomain(group.domain, o, n),
|
||||
itemBuilder: (context, i) => Padding(
|
||||
for (var i = 0; i < group.loads.length; i++)
|
||||
Padding(
|
||||
key: ValueKey('load-${group.loads[i].id}'),
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: LoadConfigCard(
|
||||
entry: group.loads[i],
|
||||
handleIndex: i,
|
||||
reordonnable: group.loads.length > 1,
|
||||
indexDansDomaine: i,
|
||||
totalDansDomaine: group.loads.length,
|
||||
provider: provider,
|
||||
telemetry: telemetry,
|
||||
onConfigure: onConfigure,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@ -193,11 +202,11 @@ class _DomainBlock extends StatelessWidget {
|
||||
/// Carte d'une charge : identité, classement, rang, télémétrie, et l'accès au mécanisme.
|
||||
class LoadConfigCard extends StatelessWidget {
|
||||
final LoadConfigEntry entry;
|
||||
final int handleIndex;
|
||||
|
||||
/// Faux quand la charge est **seule dans son domaine** : il n'y a alors rien à
|
||||
/// réordonner ici, et l'ordre se change au niveau des domaines.
|
||||
final bool reordonnable;
|
||||
/// Position dans son domaine, et taille du domaine. Ensemble, ils disent si un
|
||||
/// réordonnancement INTERNE a un sens — seul dans son domaine, il n'y a rien à déplacer,
|
||||
/// et l'ordre se change alors en glissant le domaine.
|
||||
final int indexDansDomaine;
|
||||
final int totalDansDomaine;
|
||||
|
||||
final LoadConfigProvider provider;
|
||||
final LoadTelemetryProvider telemetry;
|
||||
@ -206,8 +215,8 @@ class LoadConfigCard extends StatelessWidget {
|
||||
const LoadConfigCard({
|
||||
super.key,
|
||||
required this.entry,
|
||||
required this.handleIndex,
|
||||
this.reordonnable = true,
|
||||
required this.indexDansDomaine,
|
||||
required this.totalDansDomaine,
|
||||
required this.provider,
|
||||
required this.telemetry,
|
||||
required this.onConfigure,
|
||||
@ -264,47 +273,47 @@ class LoadConfigCard extends StatelessWidget {
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Seule dans son domaine, une charge n'a nulle part où aller : la poignée
|
||||
// serait un mensonge. On garde le rang, on retire la prise.
|
||||
_PoigneeOuPas(
|
||||
key: ValueKey('poignee-charge-${entry.id}'),
|
||||
actif: reordonnable,
|
||||
index: handleIndex,
|
||||
// `Container` transparent : rend la ZONE ENTIÈRE préhensible.
|
||||
// `ReorderableDragStartListener` défère le test de collision à ses
|
||||
// enfants ; sans ça, le vide de 3 px entre l'icône et la pastille ne
|
||||
// saisit rien — une bande morte invisible, au milieu d'une poignée qui
|
||||
// paraît d'un seul tenant.
|
||||
child: Container(
|
||||
color: Colors.transparent,
|
||||
padding: const EdgeInsets.only(top: 1, right: 9),
|
||||
child: Column(children: [
|
||||
Icon(
|
||||
reordonnable
|
||||
? Icons.drag_indicator_rounded
|
||||
: Icons.remove_rounded,
|
||||
size: 20,
|
||||
color: reordonnable ? muted : muted.withValues(alpha: 0.35),
|
||||
// Le rang, et — si le domaine porte plusieurs charges — de quoi les
|
||||
// ordonner entre elles. Le glissement, lui, appartient au domaine.
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 1, right: 9),
|
||||
child: Column(children: [
|
||||
Container(
|
||||
width: 20,
|
||||
height: 20,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: EtmTokens.brand.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
// Le rang est ce que le glisser-déposer produit : il se lit ici,
|
||||
// pas noyé dans une phrase.
|
||||
Container(
|
||||
width: 20,
|
||||
height: 20,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: EtmTokens.brand.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text('${entry.priority}',
|
||||
style: const TextStyle(
|
||||
fontSize: 10.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: EtmTokens.brand)),
|
||||
child: Text('${entry.priority}',
|
||||
style: const TextStyle(
|
||||
fontSize: 10.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: EtmTokens.brand)),
|
||||
),
|
||||
if (totalDansDomaine > 1) ...[
|
||||
const SizedBox(height: 2),
|
||||
_FlecheRang(
|
||||
key: ValueKey('monter-${entry.id}'),
|
||||
icone: Icons.keyboard_arrow_up_rounded,
|
||||
actif: indexDansDomaine > 0,
|
||||
onTap: () => provider.reorderWithinDomain(
|
||||
entry.domain ?? kUnclassifiedDomain,
|
||||
indexDansDomaine,
|
||||
indexDansDomaine - 1),
|
||||
),
|
||||
]),
|
||||
),
|
||||
_FlecheRang(
|
||||
key: ValueKey('descendre-${entry.id}'),
|
||||
icone: Icons.keyboard_arrow_down_rounded,
|
||||
actif: indexDansDomaine < totalDansDomaine - 1,
|
||||
onTap: () => provider.reorderWithinDomain(
|
||||
entry.domain ?? kUnclassifiedDomain,
|
||||
indexDansDomaine,
|
||||
indexDansDomaine + 2),
|
||||
),
|
||||
],
|
||||
]),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
@ -787,32 +796,33 @@ class BorneSection extends StatelessWidget {
|
||||
}
|
||||
|
||||
|
||||
/// Enveloppe une poignée : prise réelle si [actif], simple décor sinon.
|
||||
/// Petite flèche de rang, à l'intérieur d'un domaine.
|
||||
///
|
||||
/// `ReorderableDragStartListener` s'attend à vivre sous un `ReorderableListView` ; l'utiliser
|
||||
/// pour une liste d'un seul élément produirait une poignée qui s'agrippe et ne mène nulle
|
||||
/// part. Mieux vaut ne pas offrir la prise.
|
||||
class _PoigneeOuPas extends StatelessWidget {
|
||||
/// Le glisser-déposer appartient au domaine ; à l'intérieur, deux charges se réordonnent
|
||||
/// par ces flèches. Rendre les deux niveaux glissables est impossible — les listes
|
||||
/// réordonnables imbriquées se disputent le geste, et c'est l'extérieure qui gagne.
|
||||
class _FlecheRang extends StatelessWidget {
|
||||
final IconData icone;
|
||||
final bool actif;
|
||||
final int index;
|
||||
final Widget child;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _PoigneeOuPas({
|
||||
const _FlecheRang({
|
||||
super.key,
|
||||
required this.icone,
|
||||
required this.actif,
|
||||
required this.index,
|
||||
required this.child,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!actif) {
|
||||
return Tooltip(
|
||||
message: 'Seule charge de ce domaine — son rang se change en déplaçant le '
|
||||
'domaine lui-même.',
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
return ReorderableDragStartListener(index: index, child: child);
|
||||
final muted = EtmTokens.mutedOf(context);
|
||||
return InkWell(
|
||||
onTap: actif ? onTap : null,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Icon(icone,
|
||||
size: 18,
|
||||
color: actif ? muted : muted.withValues(alpha: 0.3)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -112,8 +112,12 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('sameHardware — ce qui RECONSTRUIT', () {
|
||||
test('RÉORDONNER les relais suffit — la comparaison du moteur est indexée', () {
|
||||
group('sameHardware — les relais, comparés par leur TABLE', () {
|
||||
test('réordonner des relais de puissances DISTINCTES ne reconstruit RIEN', () {
|
||||
// Comportement inversé par le moteur en `+etm16`, après signalement : la comparaison
|
||||
// porte sur la TABLE de paliers, pas sur la liste. Même ensemble, mêmes puissances,
|
||||
// ordre différent → même table → aucune reconstruction. Vérifié au banc : le journal
|
||||
// dit « 2 INCHANGÉE(S) », aucun état sûr, aucune commutation.
|
||||
final origine = chauffeEau();
|
||||
final inverse = chauffeEau(
|
||||
relays: (origine['relays'] as List)
|
||||
@ -124,16 +128,47 @@ void main() {
|
||||
final a = LoadConfigEntry(origine);
|
||||
final b = LoadConfigEntry(inverse);
|
||||
|
||||
// Même ensemble, mêmes Things, mêmes puissances — et pourtant :
|
||||
expect(sameHardware(a, b), isFalse,
|
||||
reason: 'vérifié sur .75 : relays[] inversé → adaptateur reconstruit, '
|
||||
'ancien passé par applySafeState (contacts ouverts, force)');
|
||||
expect(hardwareDiff(a, b), ['relays']);
|
||||
expect(sameHardware(a, b), isTrue,
|
||||
reason: 'la table produite est identique — rien à reconstruire');
|
||||
expect(hardwareDiff(a, b), isEmpty);
|
||||
expect(impactOfSave(avant: [a], apres: [b]).single.needsWarning, isFalse);
|
||||
});
|
||||
|
||||
final impacts = impactOfSave(avant: [a], apres: [b]);
|
||||
expect(impacts.single.impact, LoadChangeImpact.rebuild);
|
||||
expect(impacts.single.needsWarning, isTrue);
|
||||
expect(impacts.single.rearmSeconds, 60); // max(minOnS, minOffS)
|
||||
test('échanger deux relais de MÊME puissance reconstruit — le contact change', () {
|
||||
// Le cas où l'ordre reste significatif, et le seul : à somme égale la table retient
|
||||
// la PREMIÈRE combinaison rencontrée. Échanger deux relais identiques change donc le
|
||||
// contact qui sert ce palier — autre contacteur qui s'use, autre résistance qui
|
||||
// chauffe. Un ensemble ne le verrait pas.
|
||||
const rA = '{aaaaaaaa-0000-0000-0000-00000000000a}';
|
||||
const rB = '{bbbbbbbb-0000-0000-0000-00000000000b}';
|
||||
final a = LoadConfigEntry(chauffeEau(relays: [
|
||||
{'powerW': 1000, 'thingId': rA},
|
||||
{'powerW': 1000, 'thingId': rB},
|
||||
]));
|
||||
final b = LoadConfigEntry(chauffeEau(relays: [
|
||||
{'powerW': 1000, 'thingId': rB},
|
||||
{'powerW': 1000, 'thingId': rA},
|
||||
]));
|
||||
|
||||
expect(hardwareDiff(a, b), ['relays']);
|
||||
expect(impactOfSave(avant: [a], apres: [b]).single.impact,
|
||||
LoadChangeImpact.rebuild);
|
||||
});
|
||||
|
||||
test('la table dédoublonne : deux relais identiques ne font pas 4 paliers', () {
|
||||
final t = deriveStages(const [
|
||||
LoadRelay(thingId: '{a}', powerW: 1000),
|
||||
LoadRelay(thingId: '{b}', powerW: 1000),
|
||||
]);
|
||||
expect(t.levels, [0, 1000, 2000]);
|
||||
// Le palier 1000 est servi par le PREMIER relais déclaré.
|
||||
expect(t.mapping[1], ['{a}']);
|
||||
});
|
||||
|
||||
test('la table du banc : trois relais → huit paliers', () {
|
||||
final t = deriveStages(LoadConfigEntry(chauffeEau()).relays);
|
||||
// Exactement ce que la box publie en télémétrie sur `.75`.
|
||||
expect(t.levels, [0, 500, 1000, 1500, 2000, 2500, 3000, 3500]);
|
||||
});
|
||||
|
||||
test('changer une puissance de contacteur reconstruit', () {
|
||||
|
||||
@ -104,7 +104,10 @@ void main() {
|
||||
});
|
||||
|
||||
group('annonce d\'impact avant écriture', () {
|
||||
test('réordonner les contacteurs déclenche un avertissement, pas un silence', () {
|
||||
test('réordonner des contacteurs de puissances distinctes n\'avertit de RIEN', () {
|
||||
// Inversé par le moteur en `+etm16` : la comparaison porte sur la TABLE de paliers,
|
||||
// pas sur la liste. Même table → aucune reconstruction → rien à annoncer. Avertir
|
||||
// ici reviendrait à effrayer sur un geste devenu gratuit.
|
||||
final config = LoadConfigProvider()..adoptForTest(_benchConfig());
|
||||
|
||||
final avant = config.entries.firstWhere((e) => e.id == 'chauffe-eau');
|
||||
@ -115,9 +118,23 @@ void main() {
|
||||
config.replaceEntry('chauffe-eau', inverse);
|
||||
|
||||
expect(config.hasPendingChanges, isTrue);
|
||||
expect(config.pendingNeedsWarning, isTrue,
|
||||
reason: 'la comparaison du moteur est indexée — vérifié sur .75');
|
||||
expect(config.pendingNeedsWarning, isFalse,
|
||||
reason: 'les trois relais ont des puissances distinctes : même table');
|
||||
});
|
||||
|
||||
test('changer la puissance d\'un contacteur avertit — la table change', () {
|
||||
final config = LoadConfigProvider()..adoptForTest(_benchConfig());
|
||||
final avant = config.entries.firstWhere((e) => e.id == 'chauffe-eau');
|
||||
config.replaceEntry(
|
||||
'chauffe-eau',
|
||||
avant.patched(relays: [
|
||||
{'thingId': avant.relays[0].thingId, 'powerW': 800},
|
||||
for (final r in avant.relays.skip(1))
|
||||
{'thingId': r.thingId, 'powerW': r.powerW}
|
||||
]),
|
||||
);
|
||||
|
||||
expect(config.pendingNeedsWarning, isTrue);
|
||||
final impact = config
|
||||
.pendingImpact()
|
||||
.firstWhere((i) => i.loadId == 'chauffe-eau');
|
||||
|
||||
@ -1,28 +1,23 @@
|
||||
/// Le glisser-déposer du rang — **est-ce le geste, ou l'imbrication ?**
|
||||
/// Le réordonnancement du rang — **où doit vivre la prise ?**
|
||||
///
|
||||
/// 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 :
|
||||
/// 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. **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.
|
||||
/// 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**.
|
||||
///
|
||||
/// 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.
|
||||
/// 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';
|
||||
@ -139,60 +134,62 @@ List<Map<String, dynamic>> _uneParDomaine() {
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('section SEULE — glisser la 1re charge sous la 2e réordonne',
|
||||
testWidgets('glisser un DOMAINE réordonne le service — section seule',
|
||||
(tester) async {
|
||||
final config = LoadConfigProvider()..adoptForTest(_deuxDansUnDomaine());
|
||||
expect(_ordre(config), ['chauffe-eau', 'seche-serviettes']);
|
||||
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-charge-chauffe-eau', 'load-seche-serviettes');
|
||||
await _glisserCle(tester, 'poignee-domaine-ecs', 'load-pac-terrain');
|
||||
|
||||
expect(_ordre(config), ['seche-serviettes', 'chauffe-eau'],
|
||||
reason: 'le glissement doit produire un nouvel ordre');
|
||||
expect(_ordre(config), ['pac-terrain', 'chauffe-eau'],
|
||||
reason: 'le glissement du domaine doit produire un nouvel ordre');
|
||||
expect(config.hasPendingChanges, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('section DANS UN LISTVIEW — le geste survit-il à l\'imbrication ?',
|
||||
testWidgets('le geste survit à l\'imbrication dans le ListView de l\'écran',
|
||||
(tester) async {
|
||||
final config = LoadConfigProvider()..adoptForTest(_deuxDansUnDomaine());
|
||||
final config = LoadConfigProvider()..adoptForTest(_uneParDomaine());
|
||||
|
||||
await tester.pumpWidget(_montage(config: config, dansUnListView: true));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await _glisserCle(tester, 'poignee-charge-chauffe-eau', 'load-seche-serviettes');
|
||||
await _glisserCle(tester, 'poignee-domaine-ecs', 'load-pac-terrain');
|
||||
|
||||
expect(_ordre(config), ['seche-serviettes', 'chauffe-eau'],
|
||||
expect(_ordre(config), ['pac-terrain', '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',
|
||||
testWidgets('deux charges dans un domaine — les FLÈCHES les réordonnent',
|
||||
(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']);
|
||||
final config = LoadConfigProvider()..adoptForTest(_deuxDansUnDomaine());
|
||||
expect(_ordre(config), ['chauffe-eau', 'seche-serviettes']);
|
||||
|
||||
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',
|
||||
);
|
||||
}
|
||||
// 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();
|
||||
|
||||
// Et l'ordre se change bien par les flèches de domaine.
|
||||
config.reorderDomains(0, 2);
|
||||
expect(_ordre(config), ['pac-terrain', 'chauffe-eau']);
|
||||
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', () {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user