Le moteur a tranché l'entrelacement, et son argument va plus loin que le mien : grouper à l'affichage oui, à l'écriture non. Un regroupement appliqué à l'enregistrement est une machine qui réordonne ce qu'un humain a décidé — exactement ce que LM-1209 interdit au moteur. Et la contiguïté n'a jamais été un invariant : le waterfall trie sur le rang seul, `domain` n'est pas lu par l'arbitre. Il ne suffisait donc pas d'adoucir la note : c'est le modèle d'affichage qui la produisait. Un groupe était un SEAU par domaine, rassemblant les charges où qu'elles soient dans l'ordre — donc l'écran ne pouvait pas représenter un ordre entrelacé, et comme l'enregistrement réaplatit ce qui est affiché, TOUT enregistrement regroupait. La PAC reculait du rang 2 au rang 5 sans que personne l'ait demandé. Un groupe est maintenant une PLAGE CONTIGUË. L'affichage épouse l'ordre réel — deux plages `ev` séparées par `heating` s'affichent comme deux blocs — et réaplatir rend exactement ce qui a été lu. Test à l'appui : la PAC garde sa position, et l'ordre à plat écrit est identique à celui lu sur la box. Le regroupement reste possible, mais devient un GESTE : bouton « Regrouper par domaine », qui pose un brouillon — visible, annulable, rien ne part avant save(). Ce que l'enregistrement faisait en douce, quelqu'un le demande désormais. Et la note cesse d'alerter : l'ordre non groupé est une information, pas un défaut. L'annoncer comme une anomalie poussait à réparer un ordre que personne n'avait cassé — d'autant que depuis +etm27 une borne détectée entre en queue, ce qui produit un ordre non groupé sur une installation parfaitement saine. Trois conséquences trouvées en chemin, toutes dues au fait qu'un domaine peut désormais occuper plusieurs plages : · la clé de la liste réordonnable portait le DOMAINE → deux plages d'un même domaine partageaient une clé, et la liste cassait sur une assertion de sémantiques, loin de la cause. Elle porte l'id de la première charge de la plage. · le repli suivait le domaine → deux plages se repliaient ensemble. · reorderWithinDomain(domain) désignait la première plage venue → reorderWithinGroup(index). · assignDomain rejoint la DERNIÈRE plage du domaine, pas la première : remonter la charge dans la première la ferait sauter des rangs que personne n'a touchés. Retiré au passage : sansRankOriginAuto(). L'écho verbatim inchangé passe — mesuré côté moteur sur marque fraîche — donc la marque survit désormais aux gestes qui ne la concernent pas, et le badge ne s'éteint plus sur une borne que personne n'a classée. Noté au TODO : supprimer l'entrée d'une borne dont le Thing existe encore ne la recrée pas (défaut moteur en cours de correction). Le bouton réservé aux THING_MISSING protège déjà de ce cas — ne pas l'élargir. 226 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
609 lines
27 KiB
Dart
609 lines
27 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/services/load_priority.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,
|
|
NymeaService? service,
|
|
}) =>
|
|
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: service ?? 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);
|
|
});
|
|
});
|
|
|
|
group('retirer une charge — seulement quand l\'appareil a disparu', () {
|
|
List<Map<String, dynamic>> v2cConfig() =>
|
|
(_fixture('loadconfig_hems75_v2c.json')['loadConfigs'] as List)
|
|
.cast<Map<String, dynamic>>();
|
|
|
|
LoadTelemetryProvider tel() => LoadTelemetryProvider()
|
|
..adoptForTest(_fixture('loadtelemetry_hems75_thingmissing.json')['telemetry']
|
|
as Map<String, dynamic>);
|
|
|
|
testWidgets('aucune charge SAINE ne propose la suppression', (tester) async {
|
|
// Sur une charge vivante le geste n'aurait pas de sens : le plugin recrée d'office
|
|
// l'entrée d'une borne détectée au cycle suivant, et un bouton qui se défait tout
|
|
// seul fait douter de la box.
|
|
final config = LoadConfigProvider()..adoptForTest(v2cConfig());
|
|
|
|
await tester.pumpWidget(_harness(config: config, telemetry: tel()));
|
|
await tester.pump();
|
|
|
|
// Une seule charge est en défaut d'appareil sur cette trame : un seul bouton.
|
|
expect(find.text('Retirer cette charge'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('la confirmation annonce que TOUTE la configuration est réécrite',
|
|
(tester) async {
|
|
// C'est la seule chose que l'utilisateur ne peut pas deviner de l'écran :
|
|
// `SetLoadConfig` remplace l'ensemble, donc retirer une charge réécrit les autres.
|
|
final config = LoadConfigProvider()..adoptForTest(v2cConfig());
|
|
|
|
await tester.pumpWidget(_harness(config: config, telemetry: tel()));
|
|
await tester.pump();
|
|
|
|
// Le bouton vit en bas d'une liste de cinq cartes : sans `ensureVisible`, la touche
|
|
// tombe hors écran et ne fait rien — en silence.
|
|
await tester.ensureVisible(find.text('Retirer cette charge'));
|
|
await tester.pumpAndSettle();
|
|
await tester.tap(find.text('Retirer cette charge'));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('Retirer cette charge ?'), findsOneWidget);
|
|
expect(find.textContaining('réécrit TOUTE la configuration'), findsOneWidget);
|
|
// Le nom est dans la confirmation — et aussi sur la carte derrière, d'où `findsN`.
|
|
expect(find.textContaining('Terra AC'), findsNWidgets(2));
|
|
|
|
// Annuler ne touche à rien.
|
|
await tester.tap(find.text('Annuler'));
|
|
await tester.pumpAndSettle();
|
|
expect(config.entries.length, 5);
|
|
expect(find.text('Retirer cette charge ?'), findsNothing);
|
|
});
|
|
});
|
|
|
|
group('rankOrigin — le badge dit la provenance, la note alerte sur un refus', () {
|
|
List<Map<String, dynamic>> v2c() =>
|
|
(_fixture('loadconfig_hems75_v2c.json')['loadConfigs'] as List)
|
|
.cast<Map<String, dynamic>>();
|
|
|
|
/// Rangs distincts (l'état d'après `+etm27`), plus la provenance demandée.
|
|
List<Map<String, dynamic>> classe({String? origine, String cible = 'V2C'}) {
|
|
final entrees = v2c();
|
|
final ordonnees = [
|
|
for (final (i, c) in entrees.indexed)
|
|
{
|
|
...c,
|
|
'priority': i + 1,
|
|
if (origine != null && (c['label'] as String).contains(cible))
|
|
'rankOrigin': origine,
|
|
}
|
|
];
|
|
return ordonnees;
|
|
}
|
|
|
|
testWidgets('`auto` affiche le badge, sur CETTE charge seulement', (tester) async {
|
|
final config = LoadConfigProvider()..adoptForTest(classe(origine: 'auto'));
|
|
await tester.pumpWidget(_harness(
|
|
config: config,
|
|
telemetry: LoadTelemetryProvider()
|
|
..adoptForTest({'degradedMode': false, 'loads': const []})));
|
|
await tester.pump();
|
|
|
|
expect(find.textContaining('Rang posé par la box'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('`user` n\'affiche RIEN — quelqu\'un a regardé ce rang',
|
|
(tester) async {
|
|
// « J'ai vu ce rang par défaut et il me va » : c'est un choix, même sans déplacement.
|
|
final config = LoadConfigProvider()..adoptForTest(classe(origine: 'user'));
|
|
await tester.pumpWidget(_harness(
|
|
config: config,
|
|
telemetry: LoadTelemetryProvider()
|
|
..adoptForTest({'degradedMode': false, 'loads': const []})));
|
|
await tester.pump();
|
|
|
|
expect(find.textContaining('Rang posé par la box'), findsNothing);
|
|
});
|
|
|
|
testWidgets('champ ABSENT : aucun badge — l\'absence n\'affirme rien',
|
|
(tester) async {
|
|
// Une box antérieure à +etm27 ne publie pas le champ, et une entrée créée à la main
|
|
// non plus : l'écran doit être exactement celui d'avant.
|
|
final config = LoadConfigProvider()..adoptForTest(classe());
|
|
await tester.pumpWidget(_harness(
|
|
config: config,
|
|
telemetry: LoadTelemetryProvider()
|
|
..adoptForTest({'degradedMode': false, 'loads': const []})));
|
|
await tester.pump();
|
|
|
|
expect(find.textContaining('Rang posé par la box'), findsNothing);
|
|
});
|
|
|
|
testWidgets('le badge porte un geste EXPLICITE, et dit qu\'il ne classe pas',
|
|
(tester) async {
|
|
// « user » n'est pas « j'ai vu le badge » : c'est « ce rang me va, je l'assume ».
|
|
// Sans la phrase qui distingue adopter de classer, un installateur valide un ordre
|
|
// qu'il voulait changer — et n'y reviendra pas, le badge s'étant éteint.
|
|
final config = LoadConfigProvider()..adoptForTest(classe(origine: 'auto'));
|
|
await tester.pumpWidget(_harness(
|
|
config: config,
|
|
telemetry: LoadTelemetryProvider()
|
|
..adoptForTest({'degradedMode': false, 'loads': const []})));
|
|
await tester.pump();
|
|
|
|
expect(find.text('Ce rang me convient'), findsOneWidget);
|
|
expect(find.textContaining('Adopter ce rang ne le change pas'), findsOneWidget);
|
|
expect(find.textContaining('glissez la charge'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('aucun bouton d\'adoption là où il n\'y a rien à adopter',
|
|
(tester) async {
|
|
// Le geste ne s'offre que sur `auto` : proposer d'« adopter » un rang que quelqu'un
|
|
// a posé lui-même n'aurait aucun sens.
|
|
for (final origine in ['user', null]) {
|
|
final config = LoadConfigProvider()..adoptForTest(classe(origine: origine));
|
|
await tester.pumpWidget(_harness(
|
|
config: config,
|
|
telemetry: LoadTelemetryProvider()
|
|
..adoptForTest({'degradedMode': false, 'loads': const []})));
|
|
await tester.pump();
|
|
expect(find.text('Ce rang me convient'), findsNothing,
|
|
reason: 'origine=$origine');
|
|
}
|
|
});
|
|
|
|
testWidgets('la note d\'ex æquo est devenue une ALERTE avec un remède',
|
|
(tester) async {
|
|
// Sur la config du banc, qui porte encore deux charges au rang 1 : depuis +etm27
|
|
// elle sera rejetée en bloc à la première écriture, et le motif du refus ne franchit
|
|
// pas la frontière RPC.
|
|
final config = LoadConfigProvider()..adoptForTest(v2c());
|
|
await tester.pumpWidget(_harness(
|
|
config: config,
|
|
telemetry: LoadTelemetryProvider()
|
|
..adoptForTest({'degradedMode': false, 'loads': const []})));
|
|
await tester.pump();
|
|
|
|
final note = find.textContaining('Rangs à égalité');
|
|
expect(note, findsOneWidget);
|
|
final texte = tester.widget<Text>(note).data!;
|
|
expect(texte, contains('REFUSE'));
|
|
expect(texte, contains('rejetée en bloc'));
|
|
// Un remède, pas un constat.
|
|
expect(texte, contains('rangs distincts'));
|
|
// Et l'ancienne formulation, celle que le badge reprend, a disparu d'ici.
|
|
expect(texte, isNot(contains('ce n\'est pas un réglage')));
|
|
});
|
|
|
|
testWidgets('des rangs distincts font taire l\'alerte', (tester) async {
|
|
final config = LoadConfigProvider()..adoptForTest(classe());
|
|
await tester.pumpWidget(_harness(
|
|
config: config,
|
|
telemetry: LoadTelemetryProvider()
|
|
..adoptForTest({'degradedMode': false, 'loads': const []})));
|
|
await tester.pump();
|
|
|
|
expect(find.textContaining('Rangs à égalité'), findsNothing);
|
|
});
|
|
});
|
|
|
|
// ───────────────────────────────────────────────────────────────────────────
|
|
group('une borne à pleine puissance n\'est pas arbitrée — et l\'écran le nomme', () {
|
|
List<Map<String, dynamic>> v2cConfig() =>
|
|
(_fixture('loadconfig_hems75_v2c.json')['loadConfigs'] as List)
|
|
.cast<Map<String, dynamic>>();
|
|
|
|
/// `GetChargingInfos` du banc, 2026-08-28 : les deux bornes appairées y sont en
|
|
/// `ChargingModeNormal` / `ChargingStateIdle`. **L'`evChargerId` n'a pas d'accolades**,
|
|
/// là où l'id de charge de `GetLoadConfig` en a — c'est le piège que
|
|
/// `chargingModeOf` réconcilie.
|
|
NymeaService service({String mode = 'ChargingModeNormal'}) =>
|
|
NymeaService()
|
|
..adoptChargingInfosForTest([
|
|
{
|
|
'evChargerId': '4d1adafd-cafc-4813-93b1-ca8933ce71ab',
|
|
'chargingMode': mode,
|
|
'chargingState': 'ChargingStateIdle',
|
|
},
|
|
]);
|
|
|
|
testWidgets('la note est à l\'écran sur la borne en ChargingModeNormal',
|
|
(tester) async {
|
|
final config = LoadConfigProvider()..adoptForTest(v2cConfig());
|
|
final telemetry = LoadTelemetryProvider()
|
|
..adoptForTest({'degradedMode': false, 'loads': const []});
|
|
|
|
await tester.pumpWidget(_harness(
|
|
config: config, telemetry: telemetry, service: service()));
|
|
await tester.pump();
|
|
|
|
// Une seule des cinq charges est concernée : la V2C. Les deux autres bornes n'ont
|
|
// pas d'entrée dans la charge utile lue, et une absence ne se conclut pas.
|
|
expect(find.textContaining('hors arbitrage'), findsOneWidget);
|
|
expect(find.textContaining('choisir PV ou Min+PV'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('un mode éco fait taire la note', (tester) async {
|
|
final config = LoadConfigProvider()..adoptForTest(v2cConfig());
|
|
final telemetry = LoadTelemetryProvider()
|
|
..adoptForTest({'degradedMode': false, 'loads': const []});
|
|
|
|
await tester.pumpWidget(_harness(
|
|
config: config,
|
|
telemetry: telemetry,
|
|
service: service(mode: 'ChargingModeEcoWithMinCurrent')));
|
|
await tester.pump();
|
|
|
|
expect(find.textContaining('hors arbitrage'), findsNothing);
|
|
});
|
|
|
|
testWidgets('⚠️ les accolades de l\'id de charge ne doivent PAS faire rater la borne',
|
|
(tester) async {
|
|
// `GetLoadConfig` rend `{4d1adafd-…}`, `GetChargingInfos` rend `4d1adafd-…`. Comparer
|
|
// les deux tels quels ne rend jamais rien — en silence : l'écran conclurait « mode
|
|
// non lu » sur une borne parfaitement lue, et la note ne sortirait jamais.
|
|
final svc = service();
|
|
expect(svc.chargingModeOf('{4d1adafd-cafc-4813-93b1-ca8933ce71ab}'),
|
|
'ChargingModeNormal');
|
|
expect(svc.chargingModeOf('4d1adafd-cafc-4813-93b1-ca8933ce71ab'),
|
|
'ChargingModeNormal');
|
|
expect(svc.chargingModeOf('{09e520fd-d472-47c4-8887-6e7759372889}'), isNull,
|
|
reason: 'une borne sans entrée lue n\'a pas de mode — pas un mode par défaut');
|
|
});
|
|
});
|
|
|
|
// ───────────────────────────────────────────────────────────────────────────
|
|
// 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('l\'ordre non groupé s\'INFORME et s\'offre à regrouper — il n\'alerte plus',
|
|
(tester) async {
|
|
// Elle prévenait que l'enregistrement regrouperait. Il ne regroupe plus : un groupe
|
|
// est une plage contiguë, l'affichage épouse l'ordre réel, et réaplatir rend ce qui
|
|
// a été lu. La contiguïté n'a jamais été un invariant — l'arbitre trie sur le rang
|
|
// seul. Reste une information, et un geste pour qui veut grouper.
|
|
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);
|
|
final texte = tester.widget<Text>(note).data!;
|
|
expect(texte, contains('Ce n\'est pas un défaut'));
|
|
expect(texte, contains('ne le regroupera pas'));
|
|
// L'ancienne menace a disparu.
|
|
expect(texte, isNot(contains('REGROUPERA')));
|
|
expect(texte, isNot(contains('coupé en deux')));
|
|
// Et le regroupement devient un geste, offert et non subi.
|
|
expect(find.text('Regrouper par domaine'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('« Regrouper par domaine » pose un BROUILLON, il n\'écrit pas',
|
|
(tester) async {
|
|
// Le geste doit se voir et s'annuler avant écriture : c'est ce qui le distingue du
|
|
// regroupement subi qu'on vient de retirer.
|
|
final config = LoadConfigProvider()..adoptForTest(v2cConfig());
|
|
final telemetry = LoadTelemetryProvider()
|
|
..adoptForTest({'degradedMode': false, 'loads': const []});
|
|
|
|
await tester.pumpWidget(_harness(config: config, telemetry: telemetry));
|
|
await tester.pump();
|
|
expect(config.hasPendingChanges, isFalse);
|
|
|
|
await tester.tap(find.text('Regrouper par domaine'));
|
|
await tester.pump();
|
|
|
|
expect(config.hasPendingChanges, isTrue,
|
|
reason: 'un brouillon, donc annulable — rien n\'est parti');
|
|
expect(groupByDomain(config.entries).isContiguous, isFalse,
|
|
reason: 'la BOX porte toujours l\'ordre entrelacé : seul le brouillon a bougé');
|
|
});
|
|
|
|
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);
|
|
// Le geste qui, lui, résout le cas : retirer l'entrée orpheline.
|
|
expect(find.text('Retirer cette charge'), 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));
|
|
}
|