feat(config): la suppression se vérifie elle-même, et le sélecteur de compteur cesse d'être plus strict que la box
Deux suites directes du geste réel du 2026-08-28 sur le banc.
── 1. Une vérification qui arrive trop tard ne prouve rien ───────────────────
Sur le banc, la suppression a été vérifiée à la main, APRÈS un autre geste : entre-temps un
« Enregistrer » avait regroupé et renuméroté (chauffe-eau 1 → 3). Il est alors devenu
impossible de dire laquelle des deux écritures avait déplacé les rangs. Le journal de la box
montrait deux persistances à deux minutes d'écart, rien de plus.
`removeLoad` relit donc la configuration IMMÉDIATEMENT après l'écriture — sans attendre
LoadConfigChanged, qui laisserait une fenêtre où un autre geste s'intercale — et compare
clé par clé ce qu'elle a envoyé à ce que la box porte (diffPayloads, fonction pure). Les
survivantes doivent être identiques, rangs compris : c'est le contrôle de l'aller-retour
neutre appliqué à une écriture qui RETIRE.
Le message le dit : « Relu sur la box : les autres sont identiques clé par clé, rangs
compris », ou l'écart en rouge, nommé champ par champ. Sur le seul geste de l'app qui
détruit de la configuration, « la box a dit oui » ne suffisait pas.
La comparaison est structurelle : relays, sgReady et powerLevels sont des listes et des
objets, que `==` compare par référence — l'alerte aurait crié à chaque suppression, donc
plus personne ne l'aurait lue.
── 2. Le sélecteur de compteur était plus restrictif que la box ─────────────
Il filtrait sur l'interface smartmeter. Le moteur n'en regarde AUCUNE : findConfiguredThing
puis hasState("currentPower") (attachMeasurements, LM-1101). Sur .75, la classe wallbox
déclare smartmeter, la classe trydan non — la V2C était donc impossible à désigner comme
son propre compteur, alors que la box l'aurait accepté et que c'est le seul moyen de voir
« mesuré » à côté de « alloué » sur sa carte.
Le sélecteur accepte maintenant, en plus des smartmeter, les Things d'une interface
élargie qui publient l'état exigé par le moteur — ici evcharger + currentPower. Il s'élargit
là où la box est plus large, pas partout : une borne qui ne publie aucune puissance reste
dehors, sans quoi on écrirait une configuration muette sans erreur.
⚠️ Ce n'est pas la fin de l'écart : la classe trydan devrait déclarer smartmeter, ce qui
suppose un CUMUL d'énergie qu'elle ne publie pas — même manque que celui qui a bloqué
LM-1009. Lot plugin noté au TODO avec cette raison.
── Banc ──────────────────────────────────────────────────────────────────────
Chauffe-eau remis au rang 1 (lire → patcher → réécrire, seul `priority` touché, relecture
vérifiée : la box porte exactement ce qui a été envoyé). Ordre courant : chauffe-eau,
V2C, wallbox simulée, PAC.
188 tests (+7), flutter analyze inchangé à 27 remarques.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f
This commit is contained in:
parent
51a1ab929c
commit
32d50f5d80
12
TODO.md
12
TODO.md
@ -52,6 +52,18 @@
|
||||
borne avec/sans accolades), la règle, la façon de le chercher, et un renvoi depuis les
|
||||
deux sites de code plus `tools/rpc/README.md` — là où quelqu'un croise deux réponses.
|
||||
|
||||
- [ ] **Plugin V2C — déclarer `smartmeter` sur la classe `trydan`, ce qui suppose un
|
||||
cumul d'énergie.** Relevé du 2026-08-28 : la classe `wallbox` (borne simulée) déclare
|
||||
`connectable, evcharger, power, smartmeter, smartmeterconsumer` ; la classe `trydan` n'a
|
||||
que `connectable, evcharger, power`. La V2C n'est donc **pas proposée comme compteur** —
|
||||
l'app a élargi son sélecteur aux bornes publiant `currentPower` (correctif du jour), mais
|
||||
c'est un contournement de l'écart, pas sa disparition.
|
||||
**Le manque est de fond, et c'est le même que celui qui a bloqué LM-1009** : l'interface
|
||||
`smartmeter` attend un **cumul** d'énergie ; la wallbox simulée publie
|
||||
`totalEnergyConsumed`, la Trydan n'a que `sessionEnergy` / `chargeEnergy` — une session,
|
||||
pas un total. À chercher dans la carte Modbus V2C, ou à **construire** comme
|
||||
`sessionEnergy` l'a été (`1.15.2+etm13`). C'est un lot plugin, pas un correctif d'app.
|
||||
|
||||
- [ ] **Historique des états d'une borne — `sessionEnergy` n'est pas enregistré.**
|
||||
`suggestLogging` est écrasé par les préférences persistées de `things.conf` sur un Thing
|
||||
déjà appairé, et la V2C n'y a que `connected`, `charging`, `power`. La valeur courante est
|
||||
|
||||
@ -346,6 +346,7 @@ class LoadConfigProvider extends ChangeNotifier {
|
||||
final service = _service;
|
||||
if (service == null) return 'NoService';
|
||||
if (_draft != null) return 'PendingDraft';
|
||||
_removalDiff = const [];
|
||||
|
||||
// 1. LIRE — la charge utile de la box, à cet instant.
|
||||
final List<Map<String, dynamic>> frais;
|
||||
@ -380,17 +381,54 @@ class LoadConfigProvider extends ChangeNotifier {
|
||||
return err;
|
||||
}
|
||||
|
||||
// L'acquittement ne vaut pas application : on attend `LoadConfigChanged`, avec le même
|
||||
// filet de relecture que `save()`.
|
||||
// 4. RELIRE ET VÉRIFIER, tout de suite — pas « la prochaine fois ».
|
||||
//
|
||||
// L'acquittement ne vaut pas application, et sur le SEUL geste qui détruit de la
|
||||
// configuration, « la box a dit oui » ne suffit pas : il faut voir ce qu'elle porte
|
||||
// ensuite. La relecture est immédiate et synchrone du point de vue de l'appelant —
|
||||
// attendre `LoadConfigChanged` laisserait une fenêtre pendant laquelle un autre geste
|
||||
// peut s'intercaler, et c'est exactement ce qui a rendu la vérification manuelle du
|
||||
// 2026-08-28 inconcluante : deux écritures à deux minutes d'écart, impossible de dire
|
||||
// laquelle avait bougé les rangs.
|
||||
List<Map<String, dynamic>> relu;
|
||||
try {
|
||||
relu = await service.getLoadConfig();
|
||||
} catch (e) {
|
||||
_saveState = LoadSaveState.diverged;
|
||||
_saveError = 'Suppression envoyée, mais la relecture a échoué ($e).';
|
||||
_expectedSignature = null;
|
||||
notifyListeners();
|
||||
return 'RereadFailed';
|
||||
}
|
||||
|
||||
_adopt(relu);
|
||||
_expectedSignature = null;
|
||||
_confirmTimeout?.cancel();
|
||||
_confirmTimeout = Timer(const Duration(seconds: 6), () async {
|
||||
if (_saveState != LoadSaveState.pending) return;
|
||||
await load();
|
||||
_reconcile();
|
||||
});
|
||||
return null;
|
||||
|
||||
// Le contrôle de l'aller-retour neutre, appliqué à une écriture qui retire : les
|
||||
// survivantes doivent être identiques clé par clé, rangs compris.
|
||||
final ecarts = diffPayloads(reste, relu);
|
||||
if (ecarts.isEmpty) {
|
||||
_saveState = LoadSaveState.confirmed;
|
||||
_saveError = null;
|
||||
notifyListeners();
|
||||
return null;
|
||||
}
|
||||
|
||||
_saveState = LoadSaveState.diverged;
|
||||
_removalDiff = ecarts;
|
||||
_saveError = 'La charge est retirée, mais ${ecarts.length} autre(s) écart(s) : '
|
||||
'${ecarts.take(3).join(' · ')}';
|
||||
notifyListeners();
|
||||
return 'Diverged';
|
||||
}
|
||||
|
||||
/// Écarts constatés par la dernière suppression — vide quand elle n'a touché à rien
|
||||
/// d'autre. Ce n'est pas un échec de l'app : c'est ce que la box porte, et il faut le
|
||||
/// montrer plutôt que de le taire.
|
||||
List<String> get removalDiff => List.unmodifiable(_removalDiff);
|
||||
List<String> _removalDiff = const [];
|
||||
|
||||
void _onBoxConfirmed(List<Map<String, dynamic>> configs) {
|
||||
_adopt(configs);
|
||||
_reconcile();
|
||||
|
||||
@ -1571,6 +1571,13 @@ class _CompteurEtSonde extends StatelessWidget {
|
||||
child: ThingPicker(
|
||||
service: service,
|
||||
interfaceRequise: 'smartmeter',
|
||||
// Le moteur ne filtre sur aucune interface : `findConfiguredThing` puis
|
||||
// `hasState("currentPower")`. Une borne qui publie sa puissance est donc
|
||||
// un compteur valable — et c'est le seul moyen de voir « mesuré » sur
|
||||
// la carte d'une borne dont la classe ne déclare pas `smartmeter`
|
||||
// (la V2C Trydan, contrairement à la wallbox simulée).
|
||||
interfacesAussi: const ['evcharger'],
|
||||
etatRequis: 'currentPower',
|
||||
valeur: entry.meterThingId.isEmpty ? null : entry.meterThingId,
|
||||
placeholder: 'Choisir un appareil de mesure…',
|
||||
onChoisi: (id) => provider.replaceEntry(
|
||||
|
||||
@ -151,6 +151,66 @@ List<Map<String, dynamic>>? payloadWithoutLoad(
|
||||
return reste.length == frais.length ? null : reste;
|
||||
}
|
||||
|
||||
/// Compare ce qu'on a **envoyé** à ce que la box **porte ensuite**, clé par clé.
|
||||
///
|
||||
/// C'est le contrôle de l'aller-retour neutre (`tools/rpc/loadconfig_roundtrip.dart`)
|
||||
/// appliqué à une écriture qui **retire** : une suppression ne doit changer que la
|
||||
/// présence d'une charge, jamais un champ d'une autre — pas même un rang.
|
||||
///
|
||||
/// Il a fallu le faire faire à l'app plutôt qu'à l'opérateur : le 2026-08-28, la
|
||||
/// vérification a été faite à la main, après un autre geste, et il est devenu impossible
|
||||
/// de dire laquelle des deux écritures avait bougé les rangs. Une vérification qui arrive
|
||||
/// trop tard ne prouve rien.
|
||||
///
|
||||
/// Rend la liste des écarts, vide quand tout concorde. Les libellés sont destinés à être
|
||||
/// LUS par quelqu'un qui vient de voir sa configuration se réécrire.
|
||||
List<String> diffPayloads(
|
||||
List<Map<String, dynamic>> envoye, List<Map<String, dynamic>> relu) {
|
||||
final ecarts = <String>[];
|
||||
final aId = {for (final m in envoye) m['id'] as String? ?? '': m};
|
||||
final bId = {for (final m in relu) m['id'] as String? ?? '': m};
|
||||
|
||||
for (final entry in aId.entries) {
|
||||
final autre = bId[entry.key];
|
||||
if (autre == null) {
|
||||
ecarts.add('${entry.key} a disparu');
|
||||
continue;
|
||||
}
|
||||
final champs = {...entry.value.keys, ...autre.keys};
|
||||
for (final k in champs) {
|
||||
final av = entry.value[k];
|
||||
final ap = autre[k];
|
||||
if (!_memeValeur(av, ap)) {
|
||||
ecarts.add('${entry.key} · $k : $av → $ap');
|
||||
}
|
||||
}
|
||||
}
|
||||
for (final id in bId.keys) {
|
||||
if (!aId.containsKey(id)) ecarts.add('$id est apparue');
|
||||
}
|
||||
return ecarts;
|
||||
}
|
||||
|
||||
/// Égalité structurelle — les charges utiles portent des listes et des objets imbriqués
|
||||
/// (`relays`, `sgReady`, `powerLevels`), que `==` compare par référence.
|
||||
bool _memeValeur(Object? a, Object? b) {
|
||||
if (a is Map && b is Map) {
|
||||
if (a.length != b.length) return false;
|
||||
for (final k in a.keys) {
|
||||
if (!b.containsKey(k) || !_memeValeur(a[k], b[k])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (a is List && b is List) {
|
||||
if (a.length != b.length) return false;
|
||||
for (var i = 0; i < a.length; i++) {
|
||||
if (!_memeValeur(a[i], b[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return a == b;
|
||||
}
|
||||
|
||||
/// Reconstitue les deux niveaux depuis la liste brute de `GetLoadConfig`.
|
||||
///
|
||||
/// Le rang de domaine est l'**ordre d'apparition** dans l'ordre à plat trié par
|
||||
|
||||
@ -440,12 +440,19 @@ class _BoutonRetirerState extends State<_BoutonRetirer> {
|
||||
if (!mounted) return;
|
||||
setState(() => _enCours = false);
|
||||
|
||||
// La suppression relit et compare elle-même : le message dit ce que la box PORTE
|
||||
// ensuite, pas ce qu'elle a acquitté.
|
||||
final ecarts = provider.removalDiff;
|
||||
messenger.showSnackBar(SnackBar(
|
||||
duration: Duration(seconds: err == null ? 4 : 10),
|
||||
backgroundColor: err == null ? null : const Color(0xFF7F1D1D),
|
||||
content: Text(err == null
|
||||
// « Demandée » et non « faite » : l'acquittement du RPC ne vaut pas application,
|
||||
// c'est `LoadConfigChanged` qui tranche — et la liste se mettra à jour seule.
|
||||
? 'Suppression demandée. La liste se met à jour dès que la box confirme.'
|
||||
: _messageErreur(err)),
|
||||
? 'Charge retirée. Relu sur la box : les autres sont identiques clé par clé, '
|
||||
'rangs compris.'
|
||||
: err == 'Diverged'
|
||||
? 'Charge retirée, MAIS la box a changé autre chose :\n'
|
||||
'${ecarts.take(4).join('\n')}'
|
||||
: _messageErreur(err)),
|
||||
));
|
||||
}
|
||||
|
||||
@ -455,6 +462,9 @@ class _BoutonRetirerState extends State<_BoutonRetirer> {
|
||||
'NotFound' =>
|
||||
'Cette charge n\'est plus dans la configuration de la box — rien à retirer.',
|
||||
'NoService' => 'Aucune connexion à la box.',
|
||||
'RereadFailed' =>
|
||||
'Suppression envoyée, mais impossible de relire la configuration pour le '
|
||||
'vérifier. Rouvrez l\'écran avant tout autre geste.',
|
||||
_ => 'La box a refusé la suppression ($code). Rien n\'a été modifié.',
|
||||
};
|
||||
|
||||
|
||||
@ -26,6 +26,31 @@ class ThingPicker extends StatelessWidget {
|
||||
/// Interface nymea exigée (`power`, `etmvariableload`, …).
|
||||
final String interfaceRequise;
|
||||
|
||||
/// Interfaces **acceptées en plus** de [interfaceRequise], à condition que le Thing
|
||||
/// publie [etatRequis].
|
||||
///
|
||||
/// ## Pourquoi une porte de sortie au filtre
|
||||
///
|
||||
/// Le sélecteur de compteur filtrait sur `smartmeter`, et se trouvait donc **plus
|
||||
/// restrictif que la box**. Le moteur, lui, ne regarde aucune interface : il fait
|
||||
/// `findConfiguredThing(meterThingId)` puis `hasState("currentPower")`
|
||||
/// (`attachMeasurements`, LM-1101). Une borne qui publie `currentPower` est un compteur
|
||||
/// parfaitement valable pour lui.
|
||||
///
|
||||
/// Constaté sur `.75` le 2026-08-28 : la classe `wallbox` (borne simulée) déclare
|
||||
/// `smartmeter`, la classe `trydan` (V2C) non — elle n'a que `connectable, evcharger,
|
||||
/// power`. La V2C était donc **impossible à désigner comme son propre compteur**, alors
|
||||
/// que la box l'aurait accepté et que c'est le seul moyen de voir « mesuré » à côté de
|
||||
/// « alloué » sur sa carte.
|
||||
///
|
||||
/// Le filtre reste : c'est lui qui rend la liste utilisable. Il s'élargit là où la box
|
||||
/// est plus large, pas partout.
|
||||
final List<String> interfacesAussi;
|
||||
|
||||
/// État que doit publier un Thing retenu par [interfacesAussi] — le même que le moteur
|
||||
/// exige. Sans lui, l'élargissement proposerait des appareils qui ne mesurent rien.
|
||||
final String? etatRequis;
|
||||
|
||||
/// ThingId actuellement choisi, forme `{uuid}` ou nue. `null` = rien de choisi.
|
||||
final String? valeur;
|
||||
|
||||
@ -49,17 +74,33 @@ class ThingPicker extends StatelessWidget {
|
||||
required this.onChoisi,
|
||||
this.placeholder = 'Choisir un appareil…',
|
||||
this.dejaPris = const {},
|
||||
this.interfacesAussi = const [],
|
||||
this.etatRequis,
|
||||
});
|
||||
|
||||
/// Things portant [interfaceRequise], triés par nom.
|
||||
/// Things portant [interfaceRequise] — plus ceux de [interfacesAussi] qui publient
|
||||
/// [etatRequis]. Triés par nom.
|
||||
List<NymeaThing> _candidats() {
|
||||
final classesOk = {
|
||||
for (final c in service.thingClasses)
|
||||
if (c.interfaces.contains(interfaceRequise)) c.id
|
||||
};
|
||||
final classesOk = <String>{};
|
||||
final classesElargies = <String>{};
|
||||
for (final c in service.thingClasses) {
|
||||
if (c.interfaces.contains(interfaceRequise)) {
|
||||
classesOk.add(c.id);
|
||||
} else if (interfacesAussi.any(c.interfaces.contains)) {
|
||||
// L'élargissement se juge sur la CLASSE (l'état est déclaré là), pas sur la
|
||||
// valeur courante : un Thing qui publie `currentPower` reste un compteur valable
|
||||
// même quand cette puissance vaut zéro.
|
||||
if (etatRequis == null ||
|
||||
c.stateTypes.any((st) => st.name == etatRequis)) {
|
||||
classesElargies.add(c.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
final out = [
|
||||
for (final t in service.things)
|
||||
if (classesOk.contains(t.thingClassId)) t
|
||||
if (classesOk.contains(t.thingClassId) ||
|
||||
classesElargies.contains(t.thingClassId))
|
||||
t
|
||||
]..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
||||
return out;
|
||||
}
|
||||
|
||||
@ -176,6 +176,47 @@ void main() {
|
||||
}
|
||||
});
|
||||
|
||||
test('la relecture confirme quand RIEN d\'autre n\'a bougé', () {
|
||||
final envoye = payloadWithoutLoad(_bancRaw(), 'chauffe-eau')!;
|
||||
// La box rend ce qu'on lui a donné, dans un autre ordre : l'ordre de la liste n'est
|
||||
// pas une différence, la comparaison est par identifiant.
|
||||
final relu = envoye.reversed.map((m) => {...m}).toList();
|
||||
expect(diffPayloads(envoye, relu), isEmpty);
|
||||
});
|
||||
|
||||
test('un RANG changé au passage se voit — c\'est le cas du 2026-08-28', () {
|
||||
// Une suppression qui renumérote n'est plus un nettoyage, c'est un geste
|
||||
// d'arbitrage. Le 28, deux écritures à deux minutes d'écart avaient rendu
|
||||
// impossible de dire laquelle avait déplacé le chauffe-eau du rang 1 au rang 3.
|
||||
final envoye = payloadWithoutLoad(_bancRaw(), '{88160e45-7df2-48cc-9639-f0d15ae6e382}')!;
|
||||
final relu = [
|
||||
for (final m in envoye)
|
||||
if (m['id'] == 'chauffe-eau') {...m, 'priority': 3} else {...m},
|
||||
];
|
||||
final ecarts = diffPayloads(envoye, relu);
|
||||
expect(ecarts.length, 1);
|
||||
expect(ecarts.single, contains('priority'));
|
||||
expect(ecarts.single, contains('1 → 3'));
|
||||
});
|
||||
|
||||
test('une charge qui disparaît ou apparaît en plus se voit aussi', () {
|
||||
final envoye = payloadWithoutLoad(_bancRaw(), 'chauffe-eau')!;
|
||||
expect(diffPayloads(envoye, envoye.sublist(1)).single, contains('a disparu'));
|
||||
expect(
|
||||
diffPayloads(envoye.sublist(1), envoye).single, contains('est apparue'));
|
||||
});
|
||||
|
||||
test('les charges utiles imbriquées se comparent par VALEUR', () {
|
||||
// `relays` et `sgReady` sont des listes et des objets : `==` les comparerait par
|
||||
// référence, et toute relecture paraîtrait alors différente — l'alerte crierait à
|
||||
// chaque suppression, donc plus personne ne la lirait.
|
||||
final envoye = payloadWithoutLoad(_bancRaw(), 'pac-terrain')!;
|
||||
final relu = [
|
||||
for (final m in envoye) jsonDecode(jsonEncode(m)) as Map<String, dynamic>
|
||||
];
|
||||
expect(diffPayloads(envoye, relu), isEmpty);
|
||||
});
|
||||
|
||||
test('une charge absente rend NULL, pas la liste inchangée', () {
|
||||
// « Rien à supprimer » et « supprimé » ne doivent pas se ressembler côté appelant :
|
||||
// une liste inchangée serait réécrite pour rien, sur la foi d'un succès.
|
||||
|
||||
146
test/thing_picker_meter_test.dart
Normal file
146
test/thing_picker_meter_test.dart
Normal file
@ -0,0 +1,146 @@
|
||||
/// Le sélecteur de compteur était **plus restrictif que la box**.
|
||||
///
|
||||
/// Il filtrait sur l'interface `smartmeter`. Le moteur, lui, n'en regarde aucune : il fait
|
||||
/// `findConfiguredThing(meterThingId)` puis `hasState("currentPower")`
|
||||
/// (`attachMeasurements`, LM-1101).
|
||||
///
|
||||
/// Constaté sur `.75` le 2026-08-28 : la classe `wallbox` (borne simulée) déclare
|
||||
/// `smartmeter`, la classe `trydan` (V2C) non — `connectable, evcharger, power` seulement.
|
||||
/// La V2C était donc impossible à désigner comme son propre compteur, alors que c'est le
|
||||
/// seul moyen de faire apparaître « mesuré » à côté de « alloué » sur sa carte, et que la
|
||||
/// box l'aurait accepté.
|
||||
library;
|
||||
|
||||
import 'package:etm_powersync_app/models/nymea_models.dart';
|
||||
import 'package:etm_powersync_app/services/nymea_service.dart';
|
||||
import 'package:etm_powersync_app/widgets/thing_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
NymeaStateType _st(String id, String nom, {String type = 'Double'}) =>
|
||||
NymeaStateType(id: id, name: nom, displayName: nom, type: type, unit: '');
|
||||
|
||||
/// Le parc de `.75`, réduit aux trois cas qui comptent.
|
||||
NymeaService _service() => NymeaService()
|
||||
..adoptThingsForTest(
|
||||
[
|
||||
const NymeaThing(
|
||||
id: 'v2c',
|
||||
name: 'V2C Trydan',
|
||||
thingClassId: 'cls-trydan',
|
||||
setupStatus: 'ok',
|
||||
paramValues: []),
|
||||
const NymeaThing(
|
||||
id: 'sim',
|
||||
name: 'Simulated wallbox',
|
||||
thingClassId: 'cls-wallbox',
|
||||
setupStatus: 'ok',
|
||||
paramValues: []),
|
||||
const NymeaThing(
|
||||
id: 'ecs',
|
||||
name: 'ECS-Meter',
|
||||
thingClassId: 'cls-meter',
|
||||
setupStatus: 'ok',
|
||||
paramValues: []),
|
||||
// Une borne SANS puissance publiée : élargir le filtre ne doit pas la faire entrer.
|
||||
const NymeaThing(
|
||||
id: 'muette',
|
||||
name: 'Borne muette',
|
||||
thingClassId: 'cls-borne-muette',
|
||||
setupStatus: 'ok',
|
||||
paramValues: []),
|
||||
],
|
||||
[
|
||||
NymeaThingClass(
|
||||
id: 'cls-trydan',
|
||||
name: 'trydan',
|
||||
displayName: 'Trydan',
|
||||
interfaces: const ['connectable', 'evcharger', 'power'],
|
||||
stateTypes: [_st('p', 'currentPower')],
|
||||
),
|
||||
NymeaThingClass(
|
||||
id: 'cls-wallbox',
|
||||
name: 'wallbox',
|
||||
displayName: 'Simulated wallbox',
|
||||
interfaces: const ['evcharger', 'power', 'smartmeter', 'smartmeterconsumer'],
|
||||
stateTypes: [_st('p', 'currentPower')],
|
||||
),
|
||||
NymeaThingClass(
|
||||
id: 'cls-meter',
|
||||
name: 'smartMeterConsumer',
|
||||
displayName: 'Compteur',
|
||||
interfaces: const ['smartmeter', 'smartmeterconsumer'],
|
||||
stateTypes: [_st('p', 'currentPower')],
|
||||
),
|
||||
NymeaThingClass(
|
||||
id: 'cls-borne-muette',
|
||||
name: 'borneMuette',
|
||||
displayName: 'Borne muette',
|
||||
interfaces: const ['evcharger'],
|
||||
stateTypes: [_st('c', 'charging', type: 'Bool')],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
Widget _harness({
|
||||
List<String> aussi = const [],
|
||||
String? etatRequis,
|
||||
}) =>
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: ThingPicker(
|
||||
service: _service(),
|
||||
interfaceRequise: 'smartmeter',
|
||||
interfacesAussi: aussi,
|
||||
etatRequis: etatRequis,
|
||||
valeur: null,
|
||||
onChoisi: (_) {},
|
||||
placeholder: 'Choisir un appareil de mesure…',
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Future<List<String>> _liste(WidgetTester tester) async {
|
||||
await tester.tap(find.text('Choisir un appareil de mesure…'));
|
||||
await tester.pumpAndSettle();
|
||||
return tester
|
||||
.widgetList<Text>(find.byType(Text))
|
||||
.map((t) => t.data ?? '')
|
||||
.toList();
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('sans élargissement, la V2C n\'est PAS proposée', (tester) async {
|
||||
// L'état d'avant : le comportement qu'on corrige, figé pour qu'on sache ce qu'il était.
|
||||
await tester.pumpWidget(_harness());
|
||||
final noms = await _liste(tester);
|
||||
|
||||
expect(noms, contains('ECS-Meter'));
|
||||
expect(noms, contains('Simulated wallbox'));
|
||||
expect(noms.contains('V2C Trydan'), isFalse);
|
||||
});
|
||||
|
||||
testWidgets('élargi aux bornes qui publient currentPower, elle l\'est',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(
|
||||
_harness(aussi: const ['evcharger'], etatRequis: 'currentPower'));
|
||||
final noms = await _liste(tester);
|
||||
|
||||
expect(noms, contains('V2C Trydan'));
|
||||
// Les compteurs restent : le filtre s'élargit, il ne se remplace pas.
|
||||
expect(noms, contains('ECS-Meter'));
|
||||
expect(noms, contains('Simulated wallbox'));
|
||||
});
|
||||
|
||||
testWidgets('une borne qui ne publie AUCUNE puissance reste dehors',
|
||||
(tester) async {
|
||||
// L'élargissement suit la règle du moteur — `hasState("currentPower")` — et pas
|
||||
// « toute borne fera l'affaire ». Proposer un appareil qui ne mesure rien ferait
|
||||
// écrire une configuration muette, sans erreur.
|
||||
await tester.pumpWidget(
|
||||
_harness(aussi: const ['evcharger'], etatRequis: 'currentPower'));
|
||||
final noms = await _liste(tester);
|
||||
|
||||
expect(noms.contains('Borne muette'), isFalse);
|
||||
});
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user