/// 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 _fixture(String name) => jsonDecode(File('test/fixtures/$name').readAsStringSync()) as Map; /// 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> _benchConfig() { final raw = (_fixture('loadconfig_hems75_v3.json')['loadConfigs'] as List) .cast>(); 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); 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> v2cConfig() => (_fixture('loadconfig_hems75_v2c.json')['loadConfigs'] as List) .cast>(); LoadTelemetryProvider tel() => LoadTelemetryProvider() ..adoptForTest(_fixture('loadtelemetry_hems75_thingmissing.json')['telemetry'] as Map); 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> v2c() => (_fixture('loadconfig_hems75_v2c.json')['loadConfigs'] as List) .cast>(); /// Rangs distincts (l'état d'après `+etm27`), plus la provenance demandée. List> 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(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> v2cConfig() => (_fixture('loadconfig_hems75_v2c.json')['loadConfigs'] as List) .cast>(); /// `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> v2cConfig() => (_fixture('loadconfig_hems75_v2c.json')['loadConfigs'] as List) .cast>(); 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(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(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); 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)); }