MOTIF BELOW_MIN_POWER — premier ajout au catalogue depuis qu'il existe.
Le sens porté est précis et facile à perdre : il Y A du surplus, mais pas au bon format —
sous le plus petit cran commandable, la charge ne prend rien et le budget file à la
suivante. À distinguer de SURPLUS_INSUFFICIENT et SG_NORMAL, qui ne sortent plus que pour
un budget ≤ 0, « il n'y a rien ». Les confondre ferait chercher un défaut de production là
où il n'y a qu'une granularité. Deux variantes, départagées par la présence de `state`.
Les deux phrases voisines sont reprises en conséquence : SG_NORMAL peut enfin dire « aucun
surplus » sans risque, ce qu'elle ne pouvait pas avant, et SURPLUS_INSUFFICIENT ne parle
plus de « palier non payé » — elle dit qu'il n'y a rien à distribuer.
LE REPLI AURAIT TENU, et c'est vérifié plutôt qu'affirmé : sur BELOW_MIN_POWER privé de
`minPowerW`, l'app rend le code et ses paramètres triés au lieu de fabriquer une phrase
approximative. C'était exactement le cas prévu, et c'est le premier vrai à se présenter.
GLISSEMENT — le test POSE désormais sa condition au lieu de la subir. Il regroupe les deux
charges dans un même domaine, exerce, puis REND le classement d'origine. Deux fois de suite
il s'était contenté d'annoncer « non exercé » parce que le banc était revenu à une charge
par domaine : un test qui dépend de l'état du terrain déclare forfait au moment où on
aurait besoin de lui. Exercé sur appareil : [chauffe-eau, pac-terrain] →
[pac-terrain, chauffe-eau], classement rétabli {ecs, heating}.
COMPTEURS — rattachés pour de vrai depuis le chemin de l'app, et non par une sonde.
measuredW apparaît par charge, chacune portant SON compteur. L'affichage met la mesure à
côté de l'allocation, jamais à sa place : l'une est ce que le waterfall a décidé, l'autre
ce qui passe réellement.
Mon assertion initiale était fautive : j'exigeais des mesures DISTINCTES pour prouver le
rattachement par charge. Deux compteurs à l'arrêt lisent légitimement 0 W tous les deux —
le test échouait donc sur une installation saine. Ce qui prouve le rattachement, c'est que
chaque charge porte un compteur différent. Vérifié aussi, en détachant puis rattachant :
une charge sans compteur ne publie RIEN — null n'est pas 0, et afficher un zéro ferait
passer une absence de mesure pour une absence de consommation.
PLANCHER — préparé AVANT son arrivée au schéma, précisément à cause de ce qui vient de se
passer avec meterThingId et sensorThingId : le grisage dérivé les a activés tout seuls,
mais sur un placeholder mort. Le bloc est donc câblé maintenant, reste grisé tant que le
champ n'existe pas, et fonctionnera le jour où il apparaîtra. La box publie déjà
mechanism.minPowerW en télémétrie : on l'affiche en lecture, ce qui donne la valeur en
vigueur même sans réglage ouvert.
Tests 101/101, analyze 0 erreur.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff
544 lines
26 KiB
Dart
544 lines
26 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.
|
|
await _attendre(
|
|
tester,
|
|
() => find.text('Configurer').evaluate().length >= 2,
|
|
quoi: 'les cartes de charge à l\'écran',
|
|
);
|
|
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-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é');
|
|
});
|
|
}
|