`batteryLevelConsideration` ne gouverne aujourd'hui que la recharge du véhicule. Une fois la règle montée dans le budget, il gouvernera TOUTE l'installation : sous le seuil le budget de surplus est annulé — pas réduit — donc ni ECS, ni PAC, ni bornes. Le défaut d'usine est 0,9. Le banc `.75` est exactement dans ce cas : seuil 0,9, batterie à 50 %. Le symptôme chez un client serait le pire qui soit — un HEMS qui ne pilote plus rien par grand soleil, sans erreur, sans panne, rien à lire dans un journal. Un paramètre qui peut produire ça n'est pas un paramètre d'usine. - `BatteryReserveCard` lit et écrit le réglage, et ALERTE quand le seuil dépasse le SOC réellement atteint — le cas silencieux. Elle n'invente aucune valeur par défaut : si la box ne l'expose pas, la carte se tait plutôt que d'afficher un curseur mort. - La portée future est dite sur la carte : régler ce seuil « pour la voiture » couperait le reste demain. - Clé ARB `decisionBatteryReserve` posée, et `kCodesReserveBatterie` volontairement VIDE côté `telemetry_text` : le crochet attend le code que publiera le moteur, on ne devine pas son nom. Vérifié sur appareil contre `.75` : réglage lu (0.9 / SOC 50 %), carte affichée, campagne verte. L'assertion des cartes de charge fait désormais DÉFILER avant de compter — dans un `ListView` ce qui n'est pas à l'écran n'est pas construit, et chercher sans défiler ne prouvait que la hauteur de l'écran. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff
566 lines
28 KiB
Dart
566 lines
28 KiB
Dart
/// L'IU de configuration, **sur l'appareil, contre la vraie box**.
|
|
///
|
|
/// ## Pourquoi ce test existe sous cette forme
|
|
///
|
|
/// `adb shell input tap` est refusé sur le Redmi Note 9S :
|
|
/// `SecurityException: Injecting to another application requires INJECT_EVENTS
|
|
/// permission` — verrou MIUI qui demande un compte Xiaomi pour se lever. Un test
|
|
/// `integration_test` ne passe pas par là : il s'exécute **dans le processus de l'app** et
|
|
/// tape sur les widgets depuis l'intérieur. C'est donc bien l'application réelle, sur le
|
|
/// téléphone réel, parlant à `.75` par le réseau.
|
|
///
|
|
/// ## Ce qu'il exerce, et ce qu'il rend au banc
|
|
///
|
|
/// Il se connecte, ouvre l'écran fusionné, lit les charges **réelles**, puis écrit deux
|
|
/// fois : un renommage (mise à jour en place — aucun verrou réarmé) et sa restauration.
|
|
/// Rien de matériel n'est touché : le test ne réordonne aucun contacteur et ne désactive
|
|
/// aucune charge, parce que ces gestes ouvrent des contacts et réarment des verrous sur
|
|
/// une installation qui n'est pas la sienne.
|
|
///
|
|
/// Lancement :
|
|
/// ```sh
|
|
/// flutter test integration_test/config_on_device_test.dart -d dee659e2
|
|
/// ```
|
|
library;
|
|
|
|
import 'package:etm_powersync_app/main.dart' as app;
|
|
import 'package:etm_powersync_app/providers/energy_setup_provider.dart';
|
|
import 'package:etm_powersync_app/providers/installer_mode_provider.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/roles_devices_screen.dart';
|
|
import 'package:etm_powersync_app/services/connection_manager.dart';
|
|
import 'package:etm_powersync_app/services/nymea_service.dart';
|
|
import 'package:etm_powersync_app/services/rpc_schema.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
import 'package:integration_test/integration_test.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
/// La box de banc. `nymea-dev-rpi` doit être déjà enregistrée sur l'appareil.
|
|
const _hote = '192.168.1.75';
|
|
|
|
/// PIN installateur par défaut, posé au premier démarrage tant qu'aucun n'a été choisi.
|
|
const _pinInstallateur = '1234';
|
|
|
|
/// Pompe jusqu'à ce que [cond] soit vraie, ou échoue en le disant.
|
|
///
|
|
/// `pumpAndSettle` ne convient pas ici : l'app garde des animations et un flux de
|
|
/// notifications vivants tant qu'elle est connectée, si bien qu'elle ne « se pose »
|
|
/// jamais. On pompe donc par pas fixes.
|
|
Future<void> _attendre(
|
|
WidgetTester tester,
|
|
bool Function() cond, {
|
|
Duration limite = const Duration(seconds: 30),
|
|
String quoi = 'condition',
|
|
}) async {
|
|
final fin = DateTime.now().add(limite);
|
|
while (DateTime.now().isBefore(fin)) {
|
|
await tester.pump(const Duration(milliseconds: 250));
|
|
if (cond()) return;
|
|
}
|
|
fail('délai dépassé en attendant : $quoi');
|
|
}
|
|
|
|
void main() {
|
|
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
|
|
|
testWidgets('configuration des charges, sur appareil, contre .75',
|
|
(tester) async {
|
|
app.main();
|
|
await tester.pump(const Duration(seconds: 3));
|
|
|
|
// ── 1. Connexion à la box de banc ────────────────────────────────────────
|
|
//
|
|
// Le test ENREGISTRE lui-même la box au lieu de compter sur une installation déjà
|
|
// stockée. Ce n'est pas du confort : `flutter test integration_test` **réinstalle**
|
|
// l'app, ce qui vide `shared_preferences` et le stockage sécurisé — donc efface les
|
|
// installations. Un test qui suppose l'état du téléphone échouerait dès le premier
|
|
// run et, pire, laisserait l'appareil sans aucune box enregistrée.
|
|
//
|
|
// `connectNew()` se connecte ET persiste : la box est donc rendue au téléphone.
|
|
final ctx = tester.element(find.byType(MaterialApp).first);
|
|
final svc = Provider.of<NymeaService>(ctx, listen: false);
|
|
final cnx = Provider.of<ConnectionManager>(ctx, listen: false);
|
|
|
|
if (!svc.isConnected) {
|
|
// ignore: unawaited_futures
|
|
cnx.connectNew(host: _hote);
|
|
}
|
|
await _attendre(tester, () => svc.isConnected,
|
|
limite: const Duration(seconds: 60), quoi: 'la connexion à $_hote');
|
|
|
|
// ── 2. Le schéma est lu à la connexion, pas à l'ouverture d'un écran ─────
|
|
final schema = Provider.of<SchemaProvider>(ctx, listen: false);
|
|
await _attendre(tester, () => schema.schema.loaded,
|
|
limite: const Duration(seconds: 30), quoi: 'la lecture du schéma');
|
|
expect(schema.schema.hasMethod('NymeaEnergy.SetLoadConfig'), isTrue);
|
|
expect(schema.schema.experienceVersions['NymeaEnergy'], isNotNull);
|
|
debugPrint('[appareil] schéma lu — ${schema.versionLine()}');
|
|
|
|
// ── 3. L'écran fusionné, et les charges RÉELLES de la box ────────────────
|
|
//
|
|
// Le contexte du MaterialApp est AU-DESSUS du Router : `GoRouter.of` n'y trouve
|
|
// rien. On prend donc un contexte pris sous lui — celui d'un Scaffold affiché.
|
|
// `/energy/setup` est une route RÉSERVÉE AU MODE INSTALLATEUR : le routeur la renvoie
|
|
// vers `/` tant qu'il est verrouillé (`_isInstallerRoute`). Ce n'est pas un défaut —
|
|
// c'est le même verrou qui masque l'ajout d'installation. Sans déverrouillage, la
|
|
// navigation « réussit » sans rien monter, et le symptôme observé est « aucune
|
|
// charge » : un diagnostic parfaitement trompeur, d'où le contrôle de montage plus bas.
|
|
final installateur = Provider.of<InstallerModeProvider>(ctx, listen: false);
|
|
if (!installateur.isUnlocked) {
|
|
await installateur.unlock(_pinInstallateur);
|
|
await tester.pump(const Duration(milliseconds: 500));
|
|
}
|
|
expect(installateur.isUnlocked, isTrue,
|
|
reason: 'le PIN installateur par défaut doit ouvrir les écrans de configuration');
|
|
debugPrint('[appareil] mode installateur déverrouillé');
|
|
|
|
final ctxSousRouteur = tester.element(find.byType(Scaffold).first);
|
|
GoRouter.of(ctxSousRouteur).go('/energy/setup');
|
|
await tester.pump(const Duration(seconds: 3));
|
|
|
|
// L'écran DOIT être monté : c'est son initState qui branche LoadConfigProvider sur le
|
|
// service et déclenche la lecture. Si la navigation n'a pas eu lieu, le symptôme
|
|
// serait « aucune charge » — un diagnostic trompeur. On vérifie donc d'abord.
|
|
await _attendre(
|
|
tester,
|
|
() => find.byType(RolesDevicesScreen).evaluate().isNotEmpty,
|
|
quoi: 'le montage de l\'écran « Rôles & appareils »',
|
|
);
|
|
debugPrint('[appareil] écran de configuration monté');
|
|
|
|
final config = Provider.of<LoadConfigProvider>(ctx, listen: false);
|
|
await _attendre(tester, () => config.entries.isNotEmpty,
|
|
limite: const Duration(seconds: 40),
|
|
quoi: 'les charges de GetLoadConfig '
|
|
'(erreur du provider : ${config.error ?? "aucune"})');
|
|
|
|
final ids = config.entries.map((e) => e.id).toList()..sort();
|
|
debugPrint('[appareil] charges lues : $ids');
|
|
expect(ids, contains('chauffe-eau'));
|
|
expect(ids, contains('pac-terrain'));
|
|
|
|
// Les deux charges s'affichent, avec un bouton de détail chacune.
|
|
//
|
|
// Il faut DESCENDRE pour les atteindre : dans un `ListView`, ce qui n'est pas à
|
|
// l'écran n'est pas construit, et l'en-tête d'arbitrage puis la carte de réserve
|
|
// batterie occupent le haut. Chercher sans faire défiler ne prouverait que la hauteur
|
|
// de l'écran.
|
|
for (var pas = 0; pas < 20; pas++) {
|
|
if (find.text('Configurer').evaluate().length >= 2) break;
|
|
await tester.drag(find.byType(Scrollable).first, const Offset(0, -200));
|
|
await tester.pump(const Duration(milliseconds: 200));
|
|
}
|
|
expect(find.text('Configurer'), findsWidgets,
|
|
reason: 'les cartes de charge doivent être atteignables');
|
|
expect(find.text('chauffe-eau'), findsWidgets);
|
|
|
|
// La PAC vient du lot B-bis : avant lui, elle était arbitrée sans être
|
|
// configurable. Sa présence ICI est la preuve visible du contrat.
|
|
expect(find.text('PAC banc'), findsWidgets);
|
|
|
|
// ── 4. Une écriture RÉELLE, non matérielle, et sa confirmation ───────────
|
|
final avant = config.entries.firstWhere((e) => e.id == 'chauffe-eau');
|
|
const nouveau = 'chauffe-eau (sonde appareil)';
|
|
|
|
config.setLabel('chauffe-eau', nouveau);
|
|
await tester.pump(const Duration(milliseconds: 400));
|
|
expect(config.hasPendingChanges, isTrue);
|
|
// Un renommage ne touche à aucun matériel : rien à annoncer.
|
|
expect(config.pendingNeedsWarning, isFalse);
|
|
|
|
await config.save();
|
|
// La confirmation est `LoadConfigChanged`, PAS l'accusé du RPC — mesuré sur le banc,
|
|
// la notification arrive la première.
|
|
await _attendre(
|
|
tester,
|
|
() => config.saveState == LoadSaveState.confirmed ||
|
|
config.saveState == LoadSaveState.diverged ||
|
|
config.saveState == LoadSaveState.rejected,
|
|
limite: const Duration(seconds: 20),
|
|
quoi: 'la confirmation par LoadConfigChanged',
|
|
);
|
|
debugPrint('[appareil] écriture → ${config.saveState}');
|
|
expect(config.saveState, LoadSaveState.confirmed);
|
|
expect(
|
|
config.entries.firstWhere((e) => e.id == 'chauffe-eau').label,
|
|
nouveau,
|
|
);
|
|
|
|
// ── 5. Restauration — le banc n'appartient pas au test ──────────────────
|
|
config.setLabel('chauffe-eau', avant.label);
|
|
await tester.pump(const Duration(milliseconds: 400));
|
|
await config.save();
|
|
await _attendre(
|
|
tester,
|
|
() => config.saveState == LoadSaveState.confirmed &&
|
|
config.entries.firstWhere((e) => e.id == 'chauffe-eau').label ==
|
|
avant.label,
|
|
limite: const Duration(seconds: 20),
|
|
quoi: 'la restauration du libellé',
|
|
);
|
|
debugPrint('[appareil] libellé restauré : ${avant.label}');
|
|
|
|
// ── 5-zero. La réserve batterie est LUE et affichée ─────────────────────
|
|
//
|
|
// Un seuil qui peut geler toute l'installation ne doit pas rester un paramètre
|
|
// d'usine invisible : le symptôme, chez un client, est un HEMS muet par grand soleil,
|
|
// sans erreur ni panne.
|
|
final reserve = await svc.getBatteryLevelConsideration();
|
|
debugPrint('[appareil] réserve batterie : $reserve '
|
|
'(SOC ${svc.energyData.batterySOC.round()} %)');
|
|
expect(reserve, isNotNull, reason: '.75 expose le réglage');
|
|
await _attendre(
|
|
tester,
|
|
() => find.text('Réserve batterie').evaluate().isNotEmpty,
|
|
quoi: 'la carte de réserve batterie à l\'écran',
|
|
);
|
|
|
|
// ── 5-bis. LE GLISSEMENT, sur l'appareil, contre la vraie box ───────────
|
|
//
|
|
// Le test POSE lui-même sa condition au lieu de la subir. Le glissement n'a de sens
|
|
// que si deux charges partagent un domaine ; le banc, lui, revient régulièrement à une
|
|
// charge par domaine — deux fois de suite le test s'est donc contenté d'annoncer
|
|
// « non exercé ». Un test qui dépend de l'état du terrain ne teste rien de façon
|
|
// fiable : il déclare forfait au moment où on aurait besoin de lui.
|
|
//
|
|
// Il regroupe donc, exerce, puis REND le classement d'origine.
|
|
final domainesOrigine = {
|
|
for (final e in config.entries) e.id: e.domain ?? ''
|
|
};
|
|
debugPrint('[appareil] classement d\'origine : $domainesOrigine');
|
|
|
|
Future<void> ecrireDomaines(Map<String, String> cible) async {
|
|
for (final entry in cible.entries) {
|
|
config.assignDomain(entry.key, entry.value);
|
|
}
|
|
await tester.pump(const Duration(milliseconds: 300));
|
|
if (!config.hasPendingChanges) return;
|
|
await config.save();
|
|
await _attendre(
|
|
tester,
|
|
() => config.saveState == LoadSaveState.confirmed ||
|
|
config.saveState == LoadSaveState.diverged ||
|
|
config.saveState == LoadSaveState.rejected,
|
|
limite: const Duration(seconds: 20),
|
|
quoi: 'la confirmation du classement',
|
|
);
|
|
expect(config.saveState, LoadSaveState.confirmed);
|
|
}
|
|
|
|
// Regrouper les deux charges dans un même domaine.
|
|
const domaineTest = 'ecs';
|
|
await ecrireDomaines({for (final id in domainesOrigine.keys) id: domaineTest});
|
|
await tester.pump(const Duration(seconds: 1));
|
|
|
|
final parDomaine = <String, int>{};
|
|
for (final e in config.entries) {
|
|
parDomaine.update(e.domain ?? '', (v) => v + 1, ifAbsent: () => 1);
|
|
}
|
|
debugPrint('[appareil] regroupées : $parDomaine');
|
|
expect(parDomaine[domaineTest], greaterThan(1),
|
|
reason: 'le préambule doit avoir regroupé les charges');
|
|
|
|
// Exercer le glissement — le domaine porte maintenant plusieurs charges, donc ce sont
|
|
// les FLÈCHES de carte qui ordonnent à l'intérieur (le glisser-déposer, lui, déplace
|
|
// les domaines entre eux).
|
|
final ordreAvant = [for (final g in config.groups) ...g.loads.map((e) => e.id)];
|
|
final premier = ordreAvant.first;
|
|
final bas = find.byKey(ValueKey('descendre-$premier'));
|
|
await tester.ensureVisible(bas);
|
|
await tester.pump(const Duration(milliseconds: 300));
|
|
await tester.tap(bas);
|
|
await tester.pump(const Duration(milliseconds: 400));
|
|
|
|
final ordreApres = [for (final g in config.groups) ...g.loads.map((e) => e.id)];
|
|
debugPrint('[appareil] réordonnancement : $ordreAvant → $ordreApres');
|
|
expect(ordreApres.first, isNot(premier),
|
|
reason: 'descendre la première charge doit changer l\'ordre');
|
|
|
|
// Le rang du banc n'appartient pas au test.
|
|
config.discardDraft();
|
|
await tester.pump(const Duration(milliseconds: 300));
|
|
|
|
// RENDRE le classement d'origine.
|
|
await ecrireDomaines(domainesOrigine);
|
|
await tester.pump(const Duration(seconds: 1));
|
|
debugPrint('[appareil] classement rétabli : '
|
|
'${{for (final e in config.entries) e.id: e.domain ?? ''}}');
|
|
|
|
// ── 5-bis-bis. Le compteur racine est RELU, pas redemandé ───────────────
|
|
//
|
|
// Il vivait sur la box sans que l'app le lise jamais : le rôle repartait « à
|
|
// configurer » à chaque démarrage. Une configuration jamais lue se présente comme une
|
|
// configuration perdue — et pousse à refaire un geste inutile.
|
|
final racine = await svc.getRootMeter();
|
|
debugPrint('[appareil] compteur racine lu sur la box : ${racine ?? 'AUCUN'}');
|
|
expect(racine, isNotNull,
|
|
reason: '.75 déclare un compteur racine : l\'app doit pouvoir le lire');
|
|
|
|
final setup = Provider.of<EnergySetupProvider>(ctx, listen: false);
|
|
await setup.syncRootMeter(svc);
|
|
await tester.pump(const Duration(milliseconds: 300));
|
|
expect(setup.toConfigureRoles.map((r) => r.name), isNot(contains('gridMeter')),
|
|
reason: 'le compteur racine ne doit plus être réclamé une fois lu');
|
|
// Et les CHARGES n'ont plus rien à faire dans ce décompte : elles viennent de
|
|
// GetLoadConfig depuis la fusion.
|
|
for (final charge in ['dhw', 'heatPump', 'battery', 'evCharger']) {
|
|
expect(setup.toConfigureRoles.map((r) => r.name), isNot(contains(charge)),
|
|
reason: '« $charge » est une charge : elle ne se configure plus par un rôle');
|
|
}
|
|
debugPrint('[appareil] « à configurer » : '
|
|
'${setup.toConfigureRoles.map((r) => r.name).toList()}');
|
|
|
|
// ── 5-septies. Les bornes de recharge, toutes, et sans rang ─────────────
|
|
//
|
|
// Elles ne sont pas des LoadConfig : elles n'ont ni adapter, ni priority, ni domain,
|
|
// et n'apparaissent dans aucun domaine. Sans leur section, une borne configurée serait
|
|
// simplement invisible. La liste vient de l'interface nymea `evcharger`, donc toute
|
|
// borne appairée y entre — y compris une seconde.
|
|
final classesBorne = {
|
|
for (final c in svc.thingClasses)
|
|
if (c.interfaces.contains('evcharger')) c.id
|
|
};
|
|
final bornes = [
|
|
for (final t in svc.things)
|
|
if (classesBorne.contains(t.thingClassId)) t.name
|
|
];
|
|
debugPrint('[appareil] bornes vues : $bornes');
|
|
expect(bornes, isNotEmpty, reason: '.75 en déclare au moins une');
|
|
|
|
// ── 5-ter. Le chip de domaine PORTE son libellé ─────────────────────────
|
|
//
|
|
// Il ne rendait qu'une étiquette dessinée : le domaine d'une charge était illisible
|
|
// sur sa carte, alors que c'est lui qui décide de son groupe et de son rang.
|
|
final chip = find.byKey(const ValueKey('chip-domaine-chauffe-eau'));
|
|
await _attendre(tester, () => chip.evaluate().isNotEmpty,
|
|
quoi: 'le chip de domaine de chauffe-eau');
|
|
final texteChip = find
|
|
.descendant(of: chip, matching: find.byType(Text))
|
|
.evaluate()
|
|
.map((e) => (e.widget as Text).data)
|
|
.whereType<String>()
|
|
.toList();
|
|
debugPrint('[appareil] chip de domaine : $texteChip');
|
|
expect(texteChip.any((t) => t.trim().isNotEmpty), isTrue,
|
|
reason: 'le chip doit porter un libellé, pas seulement une icône');
|
|
|
|
// ── 5-quinquies. Les paliers viennent de la BOX, pas d'un calcul de l'app ──
|
|
//
|
|
// `+etm16` publie `mechanism.stagesW`. Une seule fonction moteur sert la construction
|
|
// du routeur, la comparaison matérielle et cette publication : l'app n'a plus de
|
|
// combinatoire à refaire, donc plus de divergence possible.
|
|
// Attendre un cycle : les écritures du préambule viennent de republier la télémétrie,
|
|
// et la lire dans la foulée peut tomber entre deux.
|
|
final teleP = Provider.of<LoadTelemetryProvider>(ctx, listen: false);
|
|
await _attendre(
|
|
tester,
|
|
() => teleP.entryFor('chauffe-eau')?.mechanism?.stagesW != null,
|
|
limite: const Duration(seconds: 150),
|
|
quoi: 'la publication de stagesW',
|
|
);
|
|
final stages = teleP.entryFor('chauffe-eau')?.mechanism?.stagesW;
|
|
debugPrint('[appareil] paliers publiés par la box : $stages');
|
|
expect(stages, isNotNull,
|
|
reason: '.75 est en +etm16 : stagesW doit être publié');
|
|
expect(stages, [0, 500, 1000, 1500, 2000, 2500, 3000, 3500]);
|
|
|
|
// ── 5-sexies. Les deux champs de mesure sont ARRIVÉS au schéma ──────────
|
|
//
|
|
// Ils étaient grisés « pas encore supporté ». `+etm16` les porte : le grisage dérivé
|
|
// du schéma les a donc activés SEUL, sans nouvelle version de l'app.
|
|
for (final champ in ['meterThingId', 'sensorThingId']) {
|
|
final sup = schema.schema.paramSupport(
|
|
'NymeaEnergy.SetLoadConfig', ['loadConfigs', '[]', champ]);
|
|
debugPrint('[appareil] $champ → $sup');
|
|
expect(sup, FieldSupport.writable,
|
|
reason: '.75 est en +etm16 : « $champ » doit être écrivable');
|
|
}
|
|
|
|
// ── 5-quater. L'écran de mécanisme, et son sélecteur ACTIF ──────────────
|
|
// Le bouton est sous la ligne de flottaison : il faut l'amener à l'écran avant de
|
|
// taper, sinon le geste ne porte sur rien.
|
|
final boutons = find.text('Configurer');
|
|
await tester.ensureVisible(boutons.first);
|
|
await tester.pump(const Duration(milliseconds: 400));
|
|
await tester.tap(boutons.first);
|
|
await tester.pump(const Duration(milliseconds: 600));
|
|
await _attendre(tester, () => find.text('Mécanisme').evaluate().isNotEmpty,
|
|
quoi: 'l\'ouverture de l\'écran de mécanisme');
|
|
|
|
for (final m in ['Routeur de relais', 'Modulable', 'SG-Ready']) {
|
|
expect(find.text(m), findsWidgets,
|
|
reason: 'le sélecteur de mécanisme doit proposer « $m »');
|
|
}
|
|
// Les sections attendues DÉPENDENT du mécanisme ouvert — c'est tout l'objet de ces
|
|
// écrans : ils ne se ressemblent pas. « Configurer » ouvre la première carte, et
|
|
// l'ordre des domaines n'est pas fixe ; on regarde donc ce qui s'est ouvert.
|
|
final estRelais = find.text('Relais commandés').evaluate().isNotEmpty ||
|
|
find.text('Contacts SG-Ready').evaluate().isEmpty;
|
|
final attendues = estRelais
|
|
? <String>['Relais commandés', 'Temporisations', 'Compteur dédié',
|
|
'Sonde de température', 'Arbitrage']
|
|
: <String>['Contacts SG-Ready', 'Table d\'états', 'Compteur dédié',
|
|
'Sonde de température', 'Arbitrage'];
|
|
|
|
final vues = <String>{};
|
|
for (var pas = 0; pas < 30 && vues.length < attendues.length; pas++) {
|
|
for (final t in attendues) {
|
|
if (find.text(t).evaluate().isNotEmpty) vues.add(t);
|
|
}
|
|
if (vues.length == attendues.length) break;
|
|
await tester.drag(
|
|
find.byKey(const ValueKey('liste-mecanisme')), const Offset(0, -150));
|
|
await tester.pump(const Duration(milliseconds: 250));
|
|
}
|
|
debugPrint('[appareil] écran de mécanisme (${estRelais ? "relais" : "SG-Ready"}) — '
|
|
'sélecteur : Routeur de relais / Modulable / SG-Ready ; sections vues : '
|
|
'${vues.join(' · ')} (${vues.length}/${attendues.length})');
|
|
for (final t in attendues) {
|
|
expect(vues, contains(t), reason: 'section « $t » jamais atteinte');
|
|
}
|
|
|
|
// On repart sans rien changer.
|
|
Navigator.of(tester.element(find.text('Arbitrage').first)).pop();
|
|
await tester.pump(const Duration(seconds: 1));
|
|
|
|
// ── 5-octies. RATTACHER les compteurs, par le chemin de l'app ───────────
|
|
//
|
|
// Premier rattachement fait depuis l'écran plutôt que par une sonde RPC. Ce qui se
|
|
// vérifie ensuite n'est pas que l'écriture est passée — c'est que la box publie
|
|
// `measuredW` PAR CHARGE, chacune la sienne, et qu'une charge sans compteur ne publie
|
|
// toujours rien. Ce dernier point compte autant que l'autre : `null` n'est pas 0, et
|
|
// afficher un zéro ferait passer une absence de mesure pour une absence de
|
|
// consommation.
|
|
// (l'écran de mécanisme a déjà été refermé plus haut — dépiler une seconde fois
|
|
// viderait la pile de navigation)
|
|
|
|
// Qui est qui, par NOM — le rattachement se pense en appareils, pas en UUID.
|
|
String? thingParNom(String motif) {
|
|
for (final t in svc.things) {
|
|
if (t.name.toLowerCase().contains(motif.toLowerCase())) return t.id;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
final compteurEcs = thingParNom('ECS-Meter');
|
|
final compteurPac = thingParNom('PAC-Meter');
|
|
debugPrint('[appareil] compteurs trouvés — ECS: $compteurEcs · PAC: $compteurPac');
|
|
|
|
if (compteurEcs != null && compteurPac != null) {
|
|
// On écrit par le MÊME chemin que le sélecteur de l'écran : patched() sur la map
|
|
// reçue, puis save(). Piloter la feuille modale du picker n'ajouterait qu'une
|
|
// fragilité de test, pas une garantie de plus — c'est cette écriture-ci qu'il porte.
|
|
for (final e in config.entries) {
|
|
final c = e.id.contains('pac') ? compteurPac : compteurEcs;
|
|
config.replaceEntry(e.id, e.patched(meterThingId: c));
|
|
}
|
|
await tester.pump(const Duration(milliseconds: 300));
|
|
await config.save();
|
|
await _attendre(
|
|
tester,
|
|
() => config.saveState == LoadSaveState.confirmed ||
|
|
config.saveState == LoadSaveState.rejected,
|
|
limite: const Duration(seconds: 20),
|
|
quoi: 'la confirmation du rattachement',
|
|
);
|
|
expect(config.saveState, LoadSaveState.confirmed,
|
|
reason: 'le rattachement d\'un compteur ne doit pas être refusé');
|
|
|
|
// Rattacher n'est PAS du matériel : aucune reconstruction, aucun verrou réarmé.
|
|
for (final e in config.entries) {
|
|
debugPrint('[appareil] ${e.id} → compteur ${e.meterThingId}');
|
|
expect(e.meterThingId, isNotEmpty);
|
|
}
|
|
|
|
// Laisser passer deux cycles : la mesure arrive avec le cycle d'arbitrage.
|
|
final tele = Provider.of<LoadTelemetryProvider>(ctx, listen: false);
|
|
await _attendre(
|
|
tester,
|
|
() => config.entries
|
|
.every((e) => tele.entryFor(e.id)?.measuredW != null),
|
|
limite: const Duration(seconds: 150),
|
|
quoi: 'la publication de measuredW pour chaque charge',
|
|
);
|
|
|
|
final mesures = {
|
|
for (final e in config.entries) e.id: tele.entryFor(e.id)?.measuredW
|
|
};
|
|
debugPrint('[appareil] measuredW par charge : $mesures');
|
|
expect(mesures.values.every((v) => v != null), isTrue);
|
|
|
|
// Ce qui prouve le rattachement PAR CHARGE, c'est que chacune porte un compteur
|
|
// DIFFÉRENT — pas que les valeurs diffèrent. Exiger des mesures distinctes était une
|
|
// erreur de ma part : deux compteurs à l'arrêt lisent légitimement 0 W tous les
|
|
// deux, et le test échouait alors sur une installation parfaitement saine.
|
|
final compteurs = {for (final e in config.entries) e.id: e.meterThingId};
|
|
debugPrint('[appareil] compteur par charge : $compteurs');
|
|
expect(compteurs.values.toSet().length, compteurs.length,
|
|
reason: 'chaque charge doit porter SON propre compteur');
|
|
|
|
// Et une charge SANS compteur ne publie rien — `null` n'est pas 0. On le vérifie en
|
|
// détachant, puis on rattache de nouveau : afficher un zéro à la place ferait passer
|
|
// une absence de mesure pour une absence de consommation.
|
|
final cible = config.entries.first;
|
|
final garde = cible.meterThingId;
|
|
config.replaceEntry(cible.id, cible.patched(meterThingId: ''));
|
|
await tester.pump(const Duration(milliseconds: 300));
|
|
await config.save();
|
|
await _attendre(
|
|
tester,
|
|
() => config.saveState == LoadSaveState.confirmed,
|
|
limite: const Duration(seconds: 20),
|
|
quoi: 'la confirmation du détachement',
|
|
);
|
|
await _attendre(
|
|
tester,
|
|
() => tele.entryFor(cible.id)?.measuredW == null,
|
|
limite: const Duration(seconds: 150),
|
|
quoi: 'l\'arrêt de publication de measuredW après détachement',
|
|
);
|
|
debugPrint('[appareil] ${cible.id} sans compteur → measuredW = '
|
|
'${tele.entryFor(cible.id)?.measuredW}');
|
|
|
|
// Rendre le rattachement : il est demandé, il reste.
|
|
final remise = config.entries.firstWhere((e) => e.id == cible.id);
|
|
config.replaceEntry(cible.id, remise.patched(meterThingId: garde));
|
|
await tester.pump(const Duration(milliseconds: 300));
|
|
await config.save();
|
|
await _attendre(
|
|
tester,
|
|
() => config.saveState == LoadSaveState.confirmed,
|
|
limite: const Duration(seconds: 20),
|
|
quoi: 'le rétablissement du compteur',
|
|
);
|
|
} else {
|
|
debugPrint('[appareil] RATTACHEMENT NON EXERCÉ — '
|
|
'ECS-Meter ou PAC-Meter absent de la box.');
|
|
}
|
|
|
|
// ── 6. On laisse l'écran affiché ────────────────────────────────────────
|
|
//
|
|
// `flutter test integration_test` referme l'app dès la dernière assertion. Sans
|
|
// cette pause, il n'y a matériellement rien à voir sur le téléphone : d'où
|
|
// l'impression que « rien ne tourne » alors que tout s'est exécuté. On tient donc
|
|
// l'écran ouvert le temps de le regarder et d'en prendre une capture.
|
|
// ORDRE IMPORTANT : on tient l'écran D'ABORD, on reverrouille APRÈS.
|
|
//
|
|
// Reverrouiller avant la pause renvoie immédiatement vers `/` — `/energy/setup` est
|
|
// une route installateur et le `redirect` du routeur s'applique en continu. La
|
|
// première version faisait l'inverse : le test passait, mais la capture montrait le
|
|
// tableau de bord. Le verrou marchait trop bien.
|
|
debugPrint('[appareil] écran maintenu 90 s — regardez le téléphone');
|
|
for (var i = 0; i < 360; i++) {
|
|
await tester.pump(const Duration(milliseconds: 250));
|
|
}
|
|
|
|
// Le mode installateur ne reste pas ouvert derrière le test.
|
|
installateur.lock();
|
|
await tester.pump(const Duration(milliseconds: 500));
|
|
debugPrint('[appareil] mode installateur reverrouillé');
|
|
});
|
|
}
|