Décision de Patrick : trois modes suffisent — Manuel, PV, Min+PV. Le boost n'est pas un quatrième mode, c'est la dérogation (+etm61). LE FAIT. Le bouton écrivait `ChargingModeNormal`, qui sort la borne de l'arbitrage et lui restitue ses valeurs manuelles stockées — manualChargingEnabled, manualMaxChargingCurrent, manualDesiredPhaseCount. Sur .75 elles valaient false / 6 A / 3 phases : mesuré le 2026-09-08 à 11:09:51, l'appui a ARRÊTÉ la charge et armé un verrou de 300 s. Pas une régression — c'est ce que Normal a toujours fait. C'est le nom qui mentait. Renommé jusque dans le code (`ChargingMode.boost` -> `ChargingMode.manuel`), pas seulement sur l'étiquette : un identifiant faux reste un piège pour le prochain lecteur, et c'est précisément ce qui a duré des mois ici. L'ordre met Manuel en tête, parce que ChargingModeNormal est aussi le mode d'office d'une borne détectée. ET LE RENOMMAGE NE SUFFISAIT PAS. « Manuel » dit QUI commande, pas ce que la borne va faire : tant que l'app n'offre pas de régler ces valeurs stockées, le mode veut dire « ce qui traînait la dernière fois ». Passer en manuel demande donc confirmation, et la confirmation dit la conséquence — la charge peut s'arrêter — AVANT, pas après. ⚠️ La confirmation ne se déclenche pas sur un ré-appui du mode déjà en place : une confirmation qu'on apprend à cliquer sans lire ne protège plus de rien. Un test le fige. Vérifié sur .75 (1.15.2+etm61) : SetLoadDerogation et ClearLoadDerogation sont dans Introspect, avec derogationEnded / durationS / restoredMode. Le contrat de la dérogation est écrit dans CLAUDE.md — durée obligatoire bornée [60, 21600], état lu dans decision.code == "DEROGATION_ACTIVE" et jamais dans le mode, fin au niveau de la trame avec six causes. Aucun bouton de boost n'est écrit : c'est le lot suivant. Et une sonde pour le moteur, faite au passage : la V2C est bien restée en ChargingModeNormal et elle est ABSENTE de loads[] — hors arbitrage. Leur hypothèse est confirmée sans redémarrage. 339 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
285 lines
12 KiB
Dart
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, 'Manuel'), findsOneWidget);
|
|
// Le mode LU est affiché à côté, et c'est celui de la box.
|
|
expect(find.textContaining('lu : Manuel'), 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);
|
|
});
|
|
});
|
|
}
|