Deux manques trouvés en usage réel sur téléphone, contre .75. ── 1. Retirer une charge dont l'appareil n'existe plus ──────────────────────── Le plugin crée d'office une entrée par borne détectée mais ne la retire pas quand le Thing s'en va : la Terra AC supprimée de nymea tenait encore le rang 4 deux jours plus tard, et l'app savait le dire sans savoir y remédier. Le bouton n'apparaît que sur THING_MISSING. Sur une charge vivante le geste n'aurait pas de sens — le plugin recrée l'entrée au cycle suivant, et un bouton qui se défait tout seul ferait douter de la box. Le chemin est celui qui était demandé, et pour la raison qui était donnée : · LIRE (GetLoadConfig, frais — pas l'état en mémoire, la box a pu changer d'avis) · PATCHER (payloadWithoutLoad : les autres charges recopiées VERBATIM, champ par champ) · RÉÉCRIRE (SetLoadConfig, qui remplace l'ensemble) Jamais une reconstruction depuis le modèle typé : un champ qu'une box plus récente porterait et que l'app ne connaît pas serait perdu pour TOUTES les charges d'un coup, en silence. Les rangs ne sont pas renumérotés — le trou laissé (1,2,3,5) ne change l'ordre de personne, alors qu'une renumérotation réécrirait le rang de charges que personne n'a touchées. Rien à moitié écrit : un refus laisse la configuration locale intacte (aucune adoption avant confirmation) et la box refuse l'ensemble ou l'accepte en entier. Le geste est refusé tant qu'un brouillon est en cours — sinon la suppression emporterait des modifications non enregistrées. La confirmation annonce ce que l'utilisateur ne peut pas deviner : retirer une charge réécrit toutes les autres. ── 2. Le mode de recharge, un seul lieu d'écriture ─────────────────────────── Les trois boutons quittent la tuile des favoris pour l'écran « Configurer » de la borne : c'est l'écran de mise en service, celui où on découvre qu'une borne neuve est en ChargingModeNormal. Deux endroits qui écrivent SetChargingInfo seraient deux comportements qui divergent. La tuile des favoris garde la LECTURE et l'avertissement « hors arbitrage » : c'est le client qui ouvre ses favoris, et il doit voir que sa borne n'est pas pilotée même s'il doit aller ailleurs pour le corriger. ⚠️ Bug latent trouvé en déplaçant : setChargingInfo écrivait toujours sur la PREMIÈRE borne configurée (_findEvChargerId). Sur le banc qui en porte trois, régler la V2C depuis son propre écran aurait réglé la wallbox simulée. La borne est maintenant visée explicitement, et evChargerFor() réconcilie les deux graphies d'identifiant (§9 du contrat de données). 181 tests (+12), flutter analyze inchangé à 27 remarques. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f
226 lines
9.0 KiB
Dart
226 lines
9.0 KiB
Dart
/// 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/models/nymea_models.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}';
|
|
|
|
/// La V2C telle que `.75` la publie : classe `trydan` portant l'interface `evcharger`, et
|
|
/// les trois états que la tuile et l'écran lisent.
|
|
NymeaService _serviceAvecBorne({String mode = 'ChargingModeNormal'}) {
|
|
const classId = 'cls-trydan';
|
|
final svc = NymeaService()
|
|
..adoptThingsForTest(
|
|
[
|
|
NymeaThing(
|
|
id: '4d1adafd-cafc-4813-93b1-ca8933ce71ab',
|
|
name: 'V2C Trydan',
|
|
thingClassId: classId,
|
|
setupStatus: 'ThingSetupStatusComplete',
|
|
paramValues: const [],
|
|
states: const [
|
|
NymeaStateValue(stateTypeId: 'st-plugged', value: false),
|
|
NymeaStateValue(stateTypeId: 'st-charging', value: false),
|
|
NymeaStateValue(stateTypeId: 'st-power', value: 0),
|
|
],
|
|
),
|
|
],
|
|
[
|
|
const NymeaThingClass(
|
|
id: classId,
|
|
name: 'trydan',
|
|
displayName: 'Trydan',
|
|
interfaces: ['evcharger'],
|
|
stateTypes: [
|
|
NymeaStateType(
|
|
id: 'st-plugged',
|
|
name: 'pluggedIn',
|
|
displayName: 'Branché',
|
|
type: 'Bool',
|
|
unit: ''),
|
|
NymeaStateType(
|
|
id: 'st-charging',
|
|
name: 'charging',
|
|
displayName: 'En charge',
|
|
type: 'Bool',
|
|
unit: ''),
|
|
NymeaStateType(
|
|
id: 'st-power',
|
|
name: 'currentPower',
|
|
displayName: 'Puissance',
|
|
type: 'Double',
|
|
unit: 'W'),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
svc.adoptChargingInfosForTest([
|
|
{
|
|
'evChargerId': '4d1adafd-cafc-4813-93b1-ca8933ce71ab',
|
|
'chargingMode': mode,
|
|
'chargingState': 'ChargingStateIdle',
|
|
},
|
|
]);
|
|
return svc;
|
|
}
|
|
|
|
Widget _harness(String loadId,
|
|
{List<Map<String, dynamic>>? config, NymeaService? service}) =>
|
|
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: service ?? 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);
|
|
});
|
|
|
|
testWidgets('les trois modes de recharge sont SUR CET ÉCRAN', (tester) async {
|
|
// Ils vivaient aux favoris ; « Configurer » est l'écran de mise en service.
|
|
await _poser(tester,
|
|
_harness(kV2c, service: _serviceAvecBorne()));
|
|
|
|
expect(find.widgetWithText(OutlinedButton, 'PV'), findsOneWidget);
|
|
expect(find.widgetWithText(OutlinedButton, 'Min+PV'), findsOneWidget);
|
|
expect(find.widgetWithText(OutlinedButton, 'Boost'), findsOneWidget);
|
|
// Le mode LU est affiché à côté, et c'est celui de la box.
|
|
expect(find.textContaining('lu : Boost'), findsOneWidget);
|
|
// Et l'avertissement d'une borne neuve, là où on la met en service.
|
|
expect(find.textContaining('hors arbitrage'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('sans ChargingInfo lue, aucun bouton — on ne devine pas un mode',
|
|
(tester) async {
|
|
// Service sans borne connue : l'écran doit le DIRE, pas allumer un bouton au hasard.
|
|
await _poser(tester, _harness(kV2c));
|
|
|
|
expect(find.widgetWithText(OutlinedButton, 'PV'), findsNothing);
|
|
expect(find.textContaining('Mode de recharge non lu'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('un mode éco fait taire l\'avertissement, les boutons restent',
|
|
(tester) async {
|
|
await _poser(
|
|
tester,
|
|
_harness(kV2c,
|
|
service: _serviceAvecBorne(mode: 'ChargingModeEco')));
|
|
|
|
expect(find.textContaining('hors arbitrage'), findsNothing);
|
|
expect(find.widgetWithText(OutlinedButton, 'PV'), findsOneWidget);
|
|
expect(find.textContaining('lu : PV'), findsOneWidget);
|
|
});
|
|
}
|