fix(ev): l'écran « Configurer » d'une borne refusait d'enregistrer, et proposait une sonde

Deux défauts trouvés sur le téléphone en dix minutes d'usage, contre .75. Aucun des 164
tests ne pouvait les voir : le premier vit dans une méthode privée de widget, le second est
une question de sens, pas de valeur.

1. « La puissance nominale est requise et doit être supérieure à 0 W » — sur une BORNE.
   _valider() faisait tomber adapter: evcharger dans la branche des charges modulables, donc
   l'écran refusait d'enregistrer un simple renommage, deux blocs sous sa propre carte qui
   explique qu'une borne ne porte ni contacts, ni paliers, ni puissance nominale. La règle
   n'est pas supprimée, elle est rendue à son périmètre — contrôle négatif à l'appui.

2. Une SONDE DE TEMPÉRATURE sur une borne de recharge. sensorThingId est générique au
   schéma — la box l'accepte sur n'importe quelle charge — mais le moteur ne le lit que pour
   publier une température (attachMeasurements, LM-1103), et une borne ne rend aucun service
   thermique : le champ ne menait nulle part. Retiré pour les bornes, gardé partout ailleurs.

Le compteur dédié, lui, RESTE sur une borne, et l'écran dit enfin à quoi il sert : c'est le
seul chemin par lequel measuredW apparaît dans la télémétrie (le moteur lit currentPower du
Thing désigné par meterThingId, et de personne d'autre). Sur une borne qui publie
elle-même currentPower, on la désigne comme son propre compteur — c'est ce qui fait
apparaître « mesuré » à côté de « alloué » sur sa carte.

Et l'écran cesse de renvoyer vers la carte véhicule du tableau de bord, supprimée ce matin :
le mode de recharge se règle depuis la tuile « Borne EV » des favoris, seul endroit de l'app
qui écrit SetChargingInfo.

Cinq tests de widget montent l'écran sur la configuration réelle du banc. Piège de harnais
noté dedans : l'écran est un ListView, qui ne construit que son viewport — sur 600 px, la
moitié des cartes n'existe pas, et chercher une absence y réussit toujours. Surface de test
portée à 4000 px.

169 tests, 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:
Patrick Schurig 2026-08-28 14:17:03 +02:00
parent 63f145d723
commit 3a8fb1586d
2 changed files with 164 additions and 1 deletions

View File

@ -322,7 +322,8 @@ class _Mecanisme extends StatelessWidget {
'branchée, et changent avec elle — les figer ici en ferait une seconde source '
'de vérité, fausse dès le prochain véhicule.\n\n'
'Ce qui se règle depuis cet écran : le libellé, le domaine, le rang. Le mode de '
'recharge, lui, se règle depuis la carte véhicule du tableau de bord.',
'recharge (PV, Min+PV, Boost) se règle depuis la tuile « Borne EV » des '
'favoris — c\'est le seul endroit de l\'app qui écrit SetChargingInfo.',
style: TextStyle(
fontSize: 12, height: 1.4, color: EtmTokens.mutedOf(context)),
),
@ -1511,9 +1512,30 @@ class _CompteurEtSonde extends StatelessWidget {
'elle n\'est qu\'une décomposition. L\'y ajouter compterait deux fois la '
'même énergie.',
),
if (entry.isEvCharger) ...[
const SizedBox(height: 6),
_Aide(
'Sur une borne, aucun compteur séparé n\'est nécessaire : elle publie '
'elle-même sa puissance. La désigner ICI comme son propre compteur est '
'ce qui fait apparaître « mesuré » à côté de « alloué » sur sa carte — '
'sans quoi le moteur n\'a rien à comparer à ce qu\'il commande.',
),
],
],
),
),
// ── Pas de sonde sur une BORNE ────────────────────────────────────
//
// `sensorThingId` est un champ générique de `LoadConfig` : le schéma l'accepte
// sur n'importe quelle charge, y compris une borne. Mais le moteur ne le lit que
// pour en publier une **température** (`attachMeasurements`, LM-1103), et une
// borne de recharge ne rend aucun service thermique : le champ ne mènerait nulle
// part. Proposé sur cet écran, il faisait chercher à quoi il sert.
//
// Le compteur, lui, RESTE : c'est le seul chemin par lequel `measuredW` apparaît
// pour cette charge dans la télémétrie — et sur une borne qui publie
// `currentPower`, on peut la désigner elle-même.
if (!entry.isEvCharger) ...[
const SizedBox(height: 14),
_Carte(
titre: 'Sonde de température',
@ -1536,6 +1558,7 @@ class _CompteurEtSonde extends StatelessWidget {
),
),
),
],
],
);
}
@ -1658,6 +1681,17 @@ class _BarreEnregistrement extends StatelessWidget {
}
}
}
} else if (entry.isEvCharger) {
// ⚠️ RIEN à valider sur une borne, et surtout pas une puissance nominale.
//
// Elle tombait dans la branche `else` avec les charges modulables, et l'écran
// refusait donc d'enregistrer un simple renommage : « La puissance nominale est
// requise » — sur la carte qui explique deux blocs plus haut qu'une borne n'en porte
// pas. Constaté sur téléphone le 2026-08-28, contre `.75`.
//
// Le contrat le confirme : l'entrée d'une borne porte `adapter`, `mode`, `domain`,
// `label`, `priority`, `enabled` — aucune charge utile de mécanisme. Ses limites
// viennent de la borne et du véhicule branché.
} else {
if (entry.maxPowerW <= 0) {
erreurs.add(

View File

@ -0,0 +1,129 @@
/// L'écran « Configurer » d'une BORNE — deux défauts trouvés sur téléphone, pas en test.
///
/// Le 2026-08-28, app installée en release contre `.75` :
///
/// 1. l'écran **refusait d'enregistrer** une borne — « La puissance nominale est requise et
/// doit être supérieure à 0 W » — alors que sa propre carte « Mécanisme » explique deux
/// blocs plus haut qu'une borne n'en porte pas. `_valider()` faisait tomber
/// `adapter: evcharger` dans la branche des charges modulables ;
/// 2. il proposait une **sonde de température** sur une borne de recharge. Le champ est
/// générique au schéma (`sensorThingId` s'écrit sur n'importe quelle charge), mais le
/// moteur ne le lit que pour publier une température — sur une borne, il ne mène nulle
/// part.
///
/// Aucun des 164 tests ne pouvait les voir : le premier vit dans une méthode privée de
/// widget, le second est une question de sens, pas de valeur.
library;
import 'dart:convert';
import 'dart:io';
import 'package:etm_powersync_app/l10n/app_localizations.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/load_mechanism_screen.dart';
import 'package:etm_powersync_app/services/nymea_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
/// Configuration réelle du banc : chauffe-eau (relais), PAC (SG-Ready), trois bornes.
List<Map<String, dynamic>> _banc() {
final raw = jsonDecode(
File('test/fixtures/loadconfig_hems75_v2c.json').readAsStringSync());
return ((raw as Map<String, dynamic>)['loadConfigs'] as List)
.cast<Map<String, dynamic>>();
}
const kV2c = '{4d1adafd-cafc-4813-93b1-ca8933ce71ab}';
Widget _harness(String loadId, {List<Map<String, dynamic>>? config}) =>
MultiProvider(
providers: [
ChangeNotifierProvider.value(
value: LoadConfigProvider()..adoptForTest(config ?? _banc())),
ChangeNotifierProvider.value(
value: LoadTelemetryProvider()
..adoptForTest({'degradedMode': false, 'loads': const []})),
ChangeNotifierProvider.value(value: SchemaProvider()),
ChangeNotifierProvider.value(value: NymeaService()),
],
// Les délégués de localisation sont indispensables : `_Identite` lit `context.l10n`
// dès sa construction, et sans eux l'écran explose avant d'afficher quoi que ce soit.
child: MaterialApp(
locale: const Locale('fr'),
localizationsDelegates: L10n.localizationsDelegates,
supportedLocales: L10n.supportedLocales,
home: LoadMechanismScreen(loadId: loadId),
),
);
/// Grande surface : l'écran est un `ListView`, qui ne construit QUE ce qui entre dans son
/// viewport. Sur 600 px de haut, le compteur, la sonde et la barre d'enregistrement ne
/// sont jamais bâtis — et `find` ne trouve rien, ce qui se lit comme un succès quand on
/// cherche une absence. C'est le piège exact que ce fichier éprouve ailleurs.
Future<void> _poser(WidgetTester tester, Widget w) async {
await tester.binding.setSurfaceSize(const Size(1000, 4000));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(w);
await tester.pump();
}
void main() {
testWidgets('une BORNE ne réclame aucune puissance nominale', (tester) async {
await _poser(tester, _harness(kV2c));
// Le blocage qui empêchait d'enregistrer un simple renommage.
expect(find.textContaining('puissance nominale est requise'), findsNothing);
// Et l'écran dit toujours POURQUOI elle n'en a pas.
expect(find.textContaining('ni contacts, ni paliers'), findsOneWidget);
});
testWidgets('une charge MODULABLE, elle, la réclame toujours', (tester) async {
// Contrôle négatif : la règle n'est pas supprimée, elle est rendue à son périmètre.
final cfg = [
for (final c in _banc())
if (c['id'] == 'chauffe-eau')
{
...c,
'adapter': 'etmvariableload',
'relays': const [],
'maxPowerW': 0,
}
else
c,
];
await _poser(tester, _harness('chauffe-eau', config: cfg));
expect(find.textContaining('puissance nominale est requise'), findsOneWidget);
});
testWidgets('pas de sonde de température sur une borne — le compteur reste',
(tester) async {
await _poser(tester, _harness(kV2c));
expect(find.text('Sonde de température'), findsNothing);
// Le compteur, lui, garde un sens : c'est le seul chemin par lequel `measuredW`
// apparaît, et une borne peut se désigner elle-même (elle publie `currentPower`).
expect(find.text('Compteur dédié'), findsOneWidget);
expect(find.textContaining('aucun compteur séparé n\'est nécessaire'),
findsOneWidget);
});
testWidgets('sur une charge thermique, la sonde est bien là', (tester) async {
await _poser(tester, _harness('chauffe-eau'));
expect(find.text('Sonde de température'), findsOneWidget);
});
testWidgets('l\'écran n\'envoie plus vers une carte véhicule supprimée',
(tester) async {
// Elle renvoyait au « tableau de bord », où la carte a été supprimée le 2026-08-28 :
// l'installateur y aurait cherché un réglage qui n'y est pas.
await _poser(tester, _harness(kV2c));
expect(find.textContaining('tableau de bord'), findsNothing);
expect(find.textContaining('tuile « Borne EV » des favoris'), findsOneWidget);
});
}