etm-powersync-app/test/load_mechanism_ev_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

285 lines
12 KiB
Dart

/// L'écran « Configurer » d'une BORNE — deux défauts trouvés sur téléphone, pas en test.
///
/// Le 2026-08-28, app installée en release contre `.75` :
///
/// 1. l'écran **refusait d'enregistrer** une borne — « La puissance nominale est requise et
/// doit être supérieure à 0 W » — alors que sa propre carte « Mécanisme » explique deux
/// blocs plus haut qu'une borne n'en porte pas. `_valider()` faisait tomber
/// `adapter: evcharger` dans la branche des charges modulables ;
/// 2. il proposait une **sonde de température** sur une borne de recharge. Le champ est
/// générique au schéma (`sensorThingId` s'écrit sur n'importe quelle charge), mais le
/// moteur ne le lit que pour publier une température — sur une borne, il ne mène nulle
/// part.
///
/// Aucun des 164 tests ne pouvait les voir : le premier vit dans une méthode privée de
/// widget, le second est une question de sens, pas de valeur.
library;
import 'dart:convert';
import 'dart:io';
import 'package:etm_powersync_app/l10n/app_localizations.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/load_mechanism_screen.dart';
import 'package:etm_powersync_app/models/nymea_models.dart';
import 'package:etm_powersync_app/services/nymea_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
/// Configuration réelle du banc : chauffe-eau (relais), PAC (SG-Ready), trois bornes.
List<Map<String, dynamic>> _banc() {
final raw = jsonDecode(
File('test/fixtures/loadconfig_hems75_v2c.json').readAsStringSync());
return ((raw as Map<String, dynamic>)['loadConfigs'] as List)
.cast<Map<String, dynamic>>();
}
const kV2c = '{4d1adafd-cafc-4813-93b1-ca8933ce71ab}';
/// La V2C telle que `.75` la publie : classe `trydan` portant l'interface `evcharger`, et
/// les trois états que la tuile et l'écran lisent.
NymeaService _serviceAvecBorne({String mode = 'ChargingModeNormal'}) {
const classId = 'cls-trydan';
final svc = NymeaService()
..adoptThingsForTest(
[
NymeaThing(
id: '4d1adafd-cafc-4813-93b1-ca8933ce71ab',
name: 'V2C Trydan',
thingClassId: classId,
setupStatus: 'ThingSetupStatusComplete',
paramValues: const [],
states: const [
NymeaStateValue(stateTypeId: 'st-plugged', value: false),
NymeaStateValue(stateTypeId: 'st-charging', value: false),
NymeaStateValue(stateTypeId: 'st-power', value: 0),
],
),
],
[
const NymeaThingClass(
id: classId,
name: 'trydan',
displayName: 'Trydan',
interfaces: ['evcharger'],
stateTypes: [
NymeaStateType(
id: 'st-plugged',
name: 'pluggedIn',
displayName: 'Branché',
type: 'Bool',
unit: ''),
NymeaStateType(
id: 'st-charging',
name: 'charging',
displayName: 'En charge',
type: 'Bool',
unit: ''),
NymeaStateType(
id: 'st-power',
name: 'currentPower',
displayName: 'Puissance',
type: 'Double',
unit: 'W'),
],
),
],
);
svc.adoptChargingInfosForTest([
{
'evChargerId': '4d1adafd-cafc-4813-93b1-ca8933ce71ab',
'chargingMode': mode,
'chargingState': 'ChargingStateIdle',
},
]);
return svc;
}
Widget _harness(String loadId,
{List<Map<String, dynamic>>? config,
NymeaService? service,
Map<String, dynamic>? telemetry}) =>
MultiProvider(
providers: [
ChangeNotifierProvider.value(
value: LoadConfigProvider()..adoptForTest(config ?? _banc())),
ChangeNotifierProvider.value(
value: LoadTelemetryProvider()
..adoptForTest(
telemetry ?? {'degradedMode': false, 'loads': const []})),
ChangeNotifierProvider.value(value: SchemaProvider()),
ChangeNotifierProvider.value(value: service ?? NymeaService()),
],
// Les délégués de localisation sont indispensables : `_Identite` lit `context.l10n`
// dès sa construction, et sans eux l'écran explose avant d'afficher quoi que ce soit.
child: MaterialApp(
locale: const Locale('fr'),
localizationsDelegates: L10n.localizationsDelegates,
supportedLocales: L10n.supportedLocales,
home: LoadMechanismScreen(loadId: loadId),
),
);
/// Grande surface : l'écran est un `ListView`, qui ne construit QUE ce qui entre dans son
/// viewport. Sur 600 px de haut, le compteur, la sonde et la barre d'enregistrement ne
/// sont jamais bâtis — et `find` ne trouve rien, ce qui se lit comme un succès quand on
/// cherche une absence. C'est le piège exact que ce fichier éprouve ailleurs.
Future<void> _poser(WidgetTester tester, Widget w) async {
// 8000 px : les écrans relais et SG-Ready sont bien plus hauts que celui d'une borne,
// et le compteur y tombait hors du viewport — donc jamais construit, donc « absent »
// pour `find`. Une absence qui n'en est pas une : le piège que ce fichier traque.
await tester.binding.setSurfaceSize(const Size(1000, 8000));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(w);
await tester.pump();
}
void main() {
testWidgets('une BORNE ne réclame aucune puissance nominale', (tester) async {
await _poser(tester, _harness(kV2c));
// Le blocage qui empêchait d'enregistrer un simple renommage.
expect(find.textContaining('puissance nominale est requise'), findsNothing);
// Et l'écran dit toujours POURQUOI elle n'en a pas.
expect(find.textContaining('ni contacts, ni paliers'), findsOneWidget);
});
testWidgets('une charge MODULABLE, elle, la réclame toujours', (tester) async {
// Contrôle négatif : la règle n'est pas supprimée, elle est rendue à son périmètre.
final cfg = [
for (final c in _banc())
if (c['id'] == 'chauffe-eau')
{
...c,
'adapter': 'etmvariableload',
'relays': const [],
'maxPowerW': 0,
}
else
c,
];
await _poser(tester, _harness('chauffe-eau', config: cfg));
expect(find.textContaining('puissance nominale est requise'), findsOneWidget);
});
testWidgets('pas de sonde de température sur une borne — le compteur reste',
(tester) async {
await _poser(tester, _harness(kV2c));
expect(find.text('Sonde de température'), findsNothing);
// Le compteur, lui, garde un sens : c'est le seul chemin par lequel une mesure
// apparaît, et une borne peut se désigner elle-même (elle publie `currentPower`).
expect(find.text('Compteur dédié'), findsOneWidget);
// Hors télémétrie, la box ne dit pas d'où viendrait la mesure : l'écran ne le
// devine pas non plus.
expect(find.textContaining('pas dans la télémétrie du dernier cycle'),
findsOneWidget);
});
testWidgets('sur une charge thermique, la sonde est bien là', (tester) async {
await _poser(tester, _harness('chauffe-eau'));
expect(find.text('Sonde de température'), findsOneWidget);
});
testWidgets('l\'écran n\'envoie plus vers une carte véhicule supprimée',
(tester) async {
// Elle renvoyait au « tableau de bord », où la carte a été supprimée le 2026-08-28 :
// l'installateur y aurait cherché un réglage qui n'y est pas.
await _poser(tester, _harness(kV2c));
expect(find.textContaining('tableau de bord'), findsNothing);
expect(find.textContaining('tuile « Borne EV » des favoris'), findsOneWidget);
});
testWidgets('les trois modes de recharge sont SUR CET ÉCRAN', (tester) async {
// Ils vivaient aux favoris ; « Configurer » est l'écran de mise en service.
await _poser(tester,
_harness(kV2c, service: _serviceAvecBorne()));
expect(find.widgetWithText(OutlinedButton, 'PV'), findsOneWidget);
expect(find.widgetWithText(OutlinedButton, 'Min+PV'), findsOneWidget);
expect(find.widgetWithText(OutlinedButton, 'Boost'), findsOneWidget);
// Le mode LU est affiché à côté, et c'est celui de la box.
expect(find.textContaining('lu : Boost'), findsOneWidget);
// Et l'avertissement d'une borne neuve, là où on la met en service.
expect(find.textContaining('hors arbitrage'), findsOneWidget);
});
testWidgets('sans ChargingInfo lue, aucun bouton — on ne devine pas un mode',
(tester) async {
// Service sans borne connue : l'écran doit le DIRE, pas allumer un bouton au hasard.
await _poser(tester, _harness(kV2c));
expect(find.widgetWithText(OutlinedButton, 'PV'), findsNothing);
expect(find.textContaining('Mode de recharge non lu'), findsOneWidget);
});
testWidgets('un mode éco fait taire l\'avertissement, les boutons restent',
(tester) async {
await _poser(
tester,
_harness(kV2c,
service: _serviceAvecBorne(mode: 'ChargingModeEco')));
expect(find.textContaining('hors arbitrage'), findsNothing);
expect(find.widgetWithText(OutlinedButton, 'PV'), findsOneWidget);
expect(find.textContaining('lu : PV'), findsOneWidget);
});
group('le compteur dédié se décide sur la SOURCE, jamais sur l\'adaptateur', () {
/// Une trame où la charge visée publie la source donnée.
Map<String, dynamic> tel(String id, String source) => {
'degradedMode': false,
'loads': [
{
'loadId': id,
'allocatedW': 3000,
'available': true,
'decision': {'code': 'SURPLUS_SETPOINT', 'params': const {}},
'measurement': source == 'none'
? {'source': 'none'}
: {'source': source, 'powerW': 2950.0},
}
],
};
testWidgets('source `device` : le compteur passe en réglage avancé, replié',
(tester) async {
await _poser(tester, _harness(kV2c, telemetry: tel(kV2c, 'device')));
// La carte reste — replier n'est pas masquer, et un réglage caché pour de bon
// finit par être réinventé ailleurs.
expect(find.text('Compteur dédié'), findsOneWidget);
expect(find.textContaining('publie déjà sa puissance'), findsOneWidget);
// Mais son contenu n'est pas déployé : ni le sélecteur, ni les aides.
expect(find.textContaining('n\'entre pas dans le budget'), findsNothing);
});
testWidgets('source `none` : le compteur est PROPOSÉ, visible', (tester) async {
// Le cas d'une PAC SG-Ready — `none` par construction (LM-1105-b) — et celui d'une
// topologie de relais mixte. Masquer le champ y couperait la seule voie de mesure.
await _poser(
tester, _harness('pac-terrain', telemetry: tel('pac-terrain', 'none')));
expect(find.text('Compteur dédié'), findsOneWidget);
// Carte DÉPLOYÉE : son contenu est monté, pas seulement son titre.
expect(find.textContaining('n\'entre pas dans le budget'), findsOneWidget);
expect(find.textContaining('seule voie vers une mesure'), findsOneWidget);
});
testWidgets('source `meter` : visible aussi, et l\'écran dit ce qu\'elle prouve',
(tester) async {
await _poser(tester,
_harness('chauffe-eau', telemetry: tel('chauffe-eau', 'meter')));
expect(find.textContaining('n\'entre pas dans le budget'), findsOneWidget);
expect(find.textContaining('constitue une preuve'), findsOneWidget);
});
});
}