etm-powersync-app/integration_test/config_on_device_test.dart
Patrick Schurig e362fb7430 chore(telemetry): l'alias measuredW est retiré — +etm26 est sur .75
Vérifié par sonde avant de couper : `measuredW` a disparu de la charge utile,
`measurement` est en ligne. L'alias n'aura vécu que le temps de l'intervalle qu'il devait
couvrir, et `MeasurementSource.legacy` part avec lui.

Ce qui reste, `MeasurementSource.inconnue`, ne le remplace pas : il couvre une box PLUS
RÉCENTE que l'app — la même règle que partout ailleurs dans ce fichier, un code inconnu
tombe sur un repli lisible plutôt que sur une phrase inventée. On affiche la valeur, on ne
prétend rien sur ce qu'un écart prouverait.

── La garantie qui manquait à ce que j'avais écrit ──────────────────────────

`measurement` est présent pour toute charge de `loads[]`, et pour elle seule. C'est elle
qui rend la lecture sûre, et elle est maintenant au contrat : son absence ne peut PAS
vouloir dire « pas mesurable » — ça, c'est `none`, un état publié — elle ne peut vouloir
dire que « charge hors de loads[] », cas où l'on ne conclut rien.

J'avais fait la distinction par prudence sur l'écran ; elle est désormais fondée sur une
garantie, pas sur une intuition.

── Les sources du banc ne sont pas celles annoncées, et c'est instructif ────

  chauffe-eau   meter, 1 500 W   porte ECS-Meter
  pac-terrain   meter,   800 W   porte PAC-Meter — pas `none`
  V2C Trydan    absente de loads[]  (pluggedIn: false)

`pac-terrain` en `meter` ne contredit pas LM-1105-b : cette règle décrit ce qu'une PAC
publie SANS compteur dédié. On lui en a posé un — ce que le brief recommandait — et le
compteur explicite l'emporte toujours. Et la V2C illustre la distinction sur machine :
absente, pas `none`. Elle porte `currentPower` et publiera sa mesure au cycle où une
voiture y sera branchée.

⚠️ Le test sur appareil exigeait `measuredW == null` après détachement du compteur.
C'était vrai avant `+etm26`, c'est faux depuis : le moteur retombe sur l'appareil quand il
publie lui-même sa puissance. Ce qui doit cesser, c'est que la source vaille `meter` sans
compteur désigné — pas que la mesure disparaisse.

CLAUDE.md porte le contrat complet et les deux pièges, avec l'illustration du banc.

205 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
2026-08-28 15:32:02 +02:00

579 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/models/load_telemetry.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)?.measurement.source == MeasurementSource.meter),
limite: const Duration(seconds: 150),
quoi: 'la publication de measurement.source = meter pour chaque charge',
);
final mesures = {
for (final e in config.entries)
e.id: tele.entryFor(e.id)?.measurement.powerW
};
debugPrint('[appareil] mesure 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 détacher le compteur doit faire CHANGER LE RÉGIME, pas forcément disparaître la
// mesure.
//
// ⚠️ Ce test exigeait `measuredW == null` après détachement. C'était vrai avant
// `+etm26` et c'est faux depuis : le moteur retombe sur l'appareil quand il publie
// lui-même sa puissance (`source: "device"`), et ne publie `none` que s'il n'y a
// vraiment rien à mesurer. Les deux sont corrects — ce qui doit cesser, c'est que la
// source vaille `meter` alors qu'aucun compteur n'est désigné.
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)?.measurement.source != MeasurementSource.meter,
limite: const Duration(seconds: 150),
quoi: 'le changement de régime après détachement (meter → device ou none)',
);
final apres = tele.entryFor(cible.id)?.measurement;
debugPrint('[appareil] ${cible.id} sans compteur → source='
'${apres?.source.name} powerW=${apres?.powerW}');
// Sous `none`, il n'y a pas de puissance — et `0` n'en est pas l'équivalent.
if (apres?.source == MeasurementSource.none) {
expect(apres?.powerW, isNull);
}
// 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é');
});
}