Deux gestes que le relevé du banc a tranchés, dans les deux sens.
1. THING_MISSING — MESURÉ, la réserve tombe
GetLoadTelemetry sur .75, 2026-08-28 10:29 UTC : la Terra AC supprimée est publiée
available=false · faultCode=THING_MISSING · decision=LOAD_UNAVAILABLE{frozenW:0} ·
mechanism.kind=evcharger. Le code SORT. Le test de rendu ne repose plus sur une trame
composée mais sur cette capture (test/fixtures/loadtelemetry_hems75_thingmissing.json).
Le masquage relevé mardi (etmvariableloadadapter.cpp:148-150, m_faulted testé avant que
THING_MISSING ne puisse s'exprimer) concerne l'adaptateur des charges VARIABLES — relais
et SG-Ready. L'adaptateur evcharger n'a pas ce chemin, d'où la différence. Il n'est donc
pas corrigé, seulement contourné par le hasard du chemin de code : remonté au brief
moteur avec la trame, parce que les deux codes envoient le client faire deux gestes
différents — « écriture échouée » fait démonter un coffret, « appareil absent » fait
rouvrir nymea.
2. EVChargingCard — elle part, et la monter aurait publié de la fiction
Trois des grandeurs qu'elle affiche ne sont JAMAIS lues sur la box : chargingMode,
chargingPower (3,6 kW) et solarSourcePercent (82 %) sont des valeurs par défaut de
EnergyData — _updateEnergyData ne les touche pas, et le mode n'est écrit qu'en optimiste
après une commande, jamais relu par GetChargingInfos. Sa feuille « Paramètres de charge »
proposait en plus un courant minimum, qui n'existe dans aucun contrat, et son bouton
« Enregistrer » ne faisait que Navigator.pop : rien n'était écrit. Ce n'était pas du code
mort à ranimer, c'était un écran à réécrire depuis le modèle.
⚠️ Et la surface vivante est pire que la morte : _EVWidget (favorites_screen.dart:419)
affiche les MÊMES valeurs par défaut comme si elles étaient mesurées — « 3600 W »,
« 82 % solaire », « Mode PV » — et écrit le mode sur des pastilles de 7 à 10 px sans
restituer un refus. Entrée 🔴 au TODO, avec ce qu'une carte de recharge peut dire
aujourd'hui : le mode lu par GetChargingInfos, la puissance mesurée du Thing, l'échéance
comme intention saisie. Ni SOC (n'existe pas), ni chargeEnergy (remis à zéro à chaque
interruption), ni courant minimum, ni phaseCount.
146 tests passent. flutter analyze : 27 remarques, deux de moins qu'avant — les deux
étaient dans le fichier supprimé.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f
332 lines
15 KiB
Dart
332 lines
15 KiB
Dart
/// L'écran de configuration FUSIONNÉ, éprouvé sur les dumps **réels** de `.75`.
|
|
///
|
|
/// > ⚠️ **Ce sont des tests de widgets, pas une vérification machine.** Ils montent les
|
|
/// > vrais widgets sur les vraies charges utiles de la box (`test/fixtures/*_v3.json`,
|
|
/// > plugin `1.15.2+etm15`), ce qui couvre le rendu et les écritures préparées — mais
|
|
/// > **aucune** ne part vers `.75` ici. Ce que ces tests ne prouvent pas est écrit dans
|
|
/// > le journal de session : l'IU n'a pas pu être pilotée sur l'appareil, l'injection
|
|
/// > d'événements ADB étant refusée par MIUI.
|
|
library;
|
|
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
|
|
import 'package:etm_powersync_app/l10n/app_localizations.dart';
|
|
import 'package:etm_powersync_app/models/load_config_entry.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/services/load_hardware.dart';
|
|
import 'package:etm_powersync_app/services/nymea_service.dart';
|
|
import 'package:etm_powersync_app/services/rpc_schema.dart';
|
|
import 'package:etm_powersync_app/widgets/controllable_loads_section.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
Map<String, dynamic> _fixture(String name) =>
|
|
jsonDecode(File('test/fixtures/$name').readAsStringSync())
|
|
as Map<String, dynamic>;
|
|
|
|
/// Config réelle de `.75`, avec les domaines tels qu'ils y sont posés aujourd'hui
|
|
/// (`chauffe-eau → ecs`, `pac-terrain → hvac`) — le multi-domaines du banc.
|
|
List<Map<String, dynamic>> _benchConfig() {
|
|
final raw = (_fixture('loadconfig_hems75_v3.json')['loadConfigs'] as List)
|
|
.cast<Map<String, dynamic>>();
|
|
return [
|
|
for (final c in raw)
|
|
{...c, 'domain': c['id'] == 'chauffe-eau' ? 'ecs' : 'hvac'},
|
|
];
|
|
}
|
|
|
|
Widget _harness({
|
|
required LoadConfigProvider config,
|
|
required LoadTelemetryProvider telemetry,
|
|
}) =>
|
|
MultiProvider(
|
|
providers: [
|
|
ChangeNotifierProvider.value(value: config),
|
|
ChangeNotifierProvider.value(value: telemetry),
|
|
// La section « Recharge véhicule » lit les Things de la box pour savoir s'il y a
|
|
// une borne. Hors connexion, la liste est vide et la section s'efface — c'est
|
|
// exactement le comportement attendu sur une installation sans borne.
|
|
ChangeNotifierProvider.value(value: NymeaService()),
|
|
],
|
|
child: MaterialApp(
|
|
locale: const Locale('fr'),
|
|
localizationsDelegates: L10n.localizationsDelegates,
|
|
supportedLocales: L10n.supportedLocales,
|
|
home: Scaffold(
|
|
body: SingleChildScrollView(
|
|
child: ControllableLoadsSection(onConfigure: (_, __) {}),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
void main() {
|
|
group('fusion — la liste vient de GetLoadConfig, pas des rôles', () {
|
|
testWidgets('les deux charges du banc apparaissent, chacune dans son domaine',
|
|
(tester) async {
|
|
final config = LoadConfigProvider()..adoptForTest(_benchConfig());
|
|
final telemetry = LoadTelemetryProvider()
|
|
..adoptForTest(_fixture('loadtelemetry_hems75_v3.json')['telemetry']
|
|
as Map<String, dynamic>);
|
|
|
|
await tester.pumpWidget(_harness(config: config, telemetry: telemetry));
|
|
await tester.pump();
|
|
|
|
// Les identifiants viennent de la box — pas d'une énumération figée de rôles.
|
|
expect(find.text('chauffe-eau'), findsWidgets);
|
|
expect(find.text('PAC banc'), findsWidgets);
|
|
|
|
// Deux mécanismes distincts, deux écrans de détail : un bouton par charge.
|
|
expect(find.text('Configurer'), findsNWidgets(2));
|
|
});
|
|
|
|
testWidgets('une charge désactivée se lit « désactivée », jamais « perdue »',
|
|
(tester) async {
|
|
final cfg = _benchConfig();
|
|
final desactivee = [
|
|
for (final c in cfg)
|
|
if (c['id'] == 'pac-terrain') {...c, 'enabled': false} else c,
|
|
];
|
|
final config = LoadConfigProvider()..adoptForTest(desactivee);
|
|
// Télémétrie VIDE : c'est exactement le cas d'une charge hors arbitrage.
|
|
final telemetry = LoadTelemetryProvider()
|
|
..adoptForTest({'degradedMode': false, 'loads': const []});
|
|
|
|
await tester.pumpWidget(_harness(config: config, telemetry: telemetry));
|
|
await tester.pump();
|
|
|
|
expect(find.textContaining('Désactivée'), findsWidgets);
|
|
expect(find.textContaining('perdue'), findsNothing);
|
|
});
|
|
});
|
|
|
|
group('annonce d\'impact avant écriture', () {
|
|
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');
|
|
final inverse = avant.patched(relays: [
|
|
for (final r in avant.relays.reversed)
|
|
{'thingId': r.thingId, 'powerW': r.powerW}
|
|
]);
|
|
config.replaceEntry('chauffe-eau', inverse);
|
|
|
|
expect(config.hasPendingChanges, isTrue);
|
|
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');
|
|
expect(impact.impact, LoadChangeImpact.rebuild);
|
|
expect(impact.changedFields, ['relays']);
|
|
});
|
|
|
|
test('renommer ou reclasser n\'avertit de rien', () {
|
|
final config = LoadConfigProvider()..adoptForTest(_benchConfig());
|
|
config.setLabel('chauffe-eau', 'Ballon 200 L');
|
|
expect(config.hasPendingChanges, isTrue);
|
|
expect(config.pendingNeedsWarning, isFalse);
|
|
});
|
|
|
|
test('désactiver la PAC avertit — elle passe par son état sûr', () {
|
|
final config = LoadConfigProvider()..adoptForTest(_benchConfig());
|
|
config.setEnabled('pac-terrain', false);
|
|
final impact =
|
|
config.pendingImpact().firstWhere((i) => i.loadId == 'pac-terrain');
|
|
expect(impact.impact, LoadChangeImpact.removed);
|
|
expect(impact.needsWarning, isTrue);
|
|
});
|
|
});
|
|
|
|
group('grisage dérivé du schéma RÉEL de .75', () {
|
|
late RpcSchema schema;
|
|
|
|
setUp(() {
|
|
final f = _fixture('schema_hems75_etm15.json');
|
|
schema = RpcSchema.fromIntrospect(f, hello: {
|
|
'version': f['_hello_version'],
|
|
'experiences': [
|
|
for (final e in (f['_hello_experiences'] as Map).entries)
|
|
{'name': e.key, 'version': e.value},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('le schéma se lit, et les versions d\'API avec lui', () {
|
|
expect(schema.loaded, isTrue);
|
|
expect(schema.experienceVersions['NymeaEnergy'], '0.8');
|
|
// La version du PAQUET (+etm15) n'est PAS sur le fil : ce qu'on annonce est
|
|
// la version d'API, qui décrit le contrat.
|
|
expect(schema.versionLineContains('etm15'), isFalse);
|
|
});
|
|
|
|
test('ChargingInfo : les champs d\'échéance existent, minCurrent NON', () {
|
|
expect(schema.typeSupport('ChargingInfo', ['targetPercentage']),
|
|
FieldSupport.writable);
|
|
expect(schema.typeSupport('ChargingInfo', ['endDateTime']),
|
|
FieldSupport.writable);
|
|
// Le mode existe, son paramètre non — c'est ce que l'écran doit dire.
|
|
expect(schema.typeSupport('ChargingInfo', ['minCurrent']),
|
|
FieldSupport.absent);
|
|
expect(schema.enumValues('ChargingMode'),
|
|
contains('ChargingModeEcoWithMinCurrent'));
|
|
});
|
|
|
|
test('ChargingInfo : chargingState est une MESURE, pas un champ manquant', () {
|
|
expect(schema.typeSupport('ChargingInfo', ['chargingState']),
|
|
FieldSupport.readOnly);
|
|
});
|
|
|
|
test('SetLoadConfig : les charges utiles de mécanisme sont au schéma', () {
|
|
for (final champ in ['relays', 'sgReady', 'maxPowerW', 'domain']) {
|
|
expect(
|
|
schema.paramSupport('NymeaEnergy.SetLoadConfig',
|
|
['loadConfigs', '[]', champ]),
|
|
FieldSupport.writable,
|
|
reason: '$champ doit être écrivable sur .75',
|
|
);
|
|
}
|
|
});
|
|
|
|
test('AirConditioning est pleinement pilotable — il n\'y a rien à griser', () {
|
|
for (final m in [
|
|
'AirConditioning.AddZone',
|
|
'AirConditioning.SetZoneThings',
|
|
'AirConditioning.SetZoneWeekSchedule',
|
|
'AirConditioning.SetZoneSetpointOverride',
|
|
]) {
|
|
expect(schema.hasMethod(m), isTrue, reason: '$m est publiée par .75');
|
|
}
|
|
});
|
|
|
|
test('un schéma NON LU ne dit pas « absent » — il dit « je ne sais pas »', () {
|
|
expect(RpcSchema.unknown.typeSupport('ChargingInfo', ['targetPercentage']),
|
|
FieldSupport.unknown);
|
|
expect(RpcSchema.unknown.paramSupport('NymeaEnergy.SetLoadConfig', ['x']),
|
|
FieldSupport.unknown);
|
|
});
|
|
});
|
|
|
|
// ───────────────────────────────────────────────────────────────────────────
|
|
// Les deux notes de la configuration réelle du 2026-08-28 — MONTÉES, pas
|
|
// seulement calculées.
|
|
//
|
|
// `tiedByPriority` et `GroupedOrder.isContiguous` ont leurs tests de fonction dans
|
|
// `evcharger_config_test.dart`, et ils passaient déjà le 2026-08-27 : la contiguïté
|
|
// était calculée, exposée par le provider, et **affichée nulle part**. Un test de
|
|
// fonction ne pouvait pas le voir. Ceux-ci montent l'écran et cherchent le texte.
|
|
group('terrain 2026-08-28 — les notes s\'affichent VRAIMENT', () {
|
|
List<Map<String, dynamic>> v2cConfig() =>
|
|
(_fixture('loadconfig_hems75_v2c.json')['loadConfigs'] as List)
|
|
.cast<Map<String, dynamic>>();
|
|
|
|
testWidgets('la note de rangs ex æquo est à l\'écran, et elle nomme les charges',
|
|
(tester) async {
|
|
final config = LoadConfigProvider()..adoptForTest(v2cConfig());
|
|
final telemetry = LoadTelemetryProvider()
|
|
..adoptForTest({'degradedMode': false, 'loads': const []});
|
|
|
|
await tester.pumpWidget(_harness(config: config, telemetry: telemetry));
|
|
await tester.pump();
|
|
|
|
final note = find.textContaining('Rangs à égalité');
|
|
expect(note, findsOneWidget);
|
|
// Nommer les charges est le point : « des rangs sont à égalité » sans dire
|
|
// lesquelles laisse l'installateur chercher dans cinq cartes.
|
|
final texte = tester.widget<Text>(note).data!;
|
|
expect(texte, contains('chauffe-eau'));
|
|
expect(texte, contains('V2C Trydan'));
|
|
});
|
|
|
|
testWidgets('la note d\'ordre entrelacé est à l\'écran, et annonce l\'effet de l\'enregistrement',
|
|
(tester) async {
|
|
final config = LoadConfigProvider()..adoptForTest(v2cConfig());
|
|
final telemetry = LoadTelemetryProvider()
|
|
..adoptForTest({'degradedMode': false, 'loads': const []});
|
|
|
|
await tester.pumpWidget(_harness(config: config, telemetry: telemetry));
|
|
await tester.pump();
|
|
|
|
final note = find.textContaining('n\'est pas groupé par domaine');
|
|
expect(note, findsOneWidget);
|
|
// Ce que l'enregistrement fera doit être dit AVANT, pas constaté après.
|
|
expect(tester.widget<Text>(note).data!, contains('REGROUPERA'));
|
|
});
|
|
|
|
testWidgets('un ordre déjà groupé et classé ne montre AUCUNE des deux notes',
|
|
(tester) async {
|
|
// Le contrôle négatif : les deux notes doivent disparaître d'elles-mêmes au
|
|
// premier vrai réglage, sinon elles deviennent du décor qu'on n'ouvre plus.
|
|
final config = LoadConfigProvider()..adoptForTest(_benchConfig());
|
|
final telemetry = LoadTelemetryProvider()
|
|
..adoptForTest({'degradedMode': false, 'loads': const []});
|
|
|
|
await tester.pumpWidget(_harness(config: config, telemetry: telemetry));
|
|
await tester.pump();
|
|
|
|
expect(find.textContaining('Rangs à égalité'), findsNothing);
|
|
expect(find.textContaining('n\'est pas groupé par domaine'), findsNothing);
|
|
});
|
|
|
|
testWidgets('THING_MISSING se lit « appareil absent », et n\'offre RIEN à lever',
|
|
(tester) async {
|
|
// Télémétrie CAPTURÉE sur `.75` le 2026-08-28 à 10:29 UTC — la réserve de la veille
|
|
// est levée par la mesure. La box publie bien `THING_MISSING` sur cette charge :
|
|
// `available: false`, `decision: LOAD_UNAVAILABLE {frozenW: 0}`,
|
|
// `mechanism.kind: evcharger`.
|
|
//
|
|
// Le masquage relevé mardi côté moteur (`etmvariableloadadapter.cpp:148-150`,
|
|
// `m_faulted` testé avant que `THING_MISSING` ne puisse s'exprimer) concerne
|
|
// l'adaptateur des charges **variables** — relais et SG-Ready. L'adaptateur
|
|
// `evcharger` n'en souffre pas, et c'est pour ça que le code sort ici.
|
|
final telemetry = LoadTelemetryProvider()
|
|
..adoptForTest(_fixture('loadtelemetry_hems75_thingmissing.json')['telemetry']
|
|
as Map<String, dynamic>);
|
|
final config = LoadConfigProvider()..adoptForTest(v2cConfig());
|
|
|
|
await tester.pumpWidget(_harness(config: config, telemetry: telemetry));
|
|
await tester.pump();
|
|
|
|
// Le bouton ne peut pas tenir sa promesse — il n'existe pas.
|
|
expect(find.text('Lever le défaut'), findsNothing);
|
|
expect(find.textContaining('La levée est une action délibérée'), findsNothing);
|
|
// À la place : les deux gestes qui, eux, résolvent le cas.
|
|
expect(find.textContaining('Réinstallez-le dans nymea'), findsOneWidget);
|
|
// Et le libellé ne doit pas se lire « votre configuration est cassée ».
|
|
expect(find.textContaining('n\'existe plus dans nymea'), findsOneWidget);
|
|
// Les deux autres bornes du banc sont ABSENTES de la télémétrie captée — aucun
|
|
// véhicule branché (`+etm24`). Elles restent affichées comme charges configurées,
|
|
// et aucune ne porte de défaut : « hors arbitrage » n'est pas « en panne ».
|
|
expect(find.text('V2C Trydan'), findsWidgets);
|
|
expect(find.textContaining('En défaut'), findsOneWidget);
|
|
});
|
|
});
|
|
}
|
|
|
|
extension on RpcSchema {
|
|
bool versionLineContains(String s) =>
|
|
(nymeaVersion ?? '').contains(s) ||
|
|
experienceVersions.values.any((v) => v.contains(s));
|
|
}
|