etm-powersync-app/test/thing_picker_meter_test.dart
Patrick Schurig 32d50f5d80 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
2026-08-28 14:56:21 +02:00

147 lines
5.0 KiB
Dart

/// 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);
});
}