etm-powersync-app/test/evcharger_config_test.dart
Patrick Schurig 51a1ab929c feat(config): retirer une charge orpheline, et régler le mode de recharge à un seul endroit
Deux manques trouvés en usage réel sur téléphone, contre .75.

── 1. Retirer une charge dont l'appareil n'existe plus ────────────────────────

Le plugin crée d'office une entrée par borne détectée mais ne la retire pas quand le Thing
s'en va : la Terra AC supprimée de nymea tenait encore le rang 4 deux jours plus tard, et
l'app savait le dire sans savoir y remédier.

Le bouton n'apparaît que sur THING_MISSING. Sur une charge vivante le geste n'aurait pas de
sens — le plugin recrée l'entrée au cycle suivant, et un bouton qui se défait tout seul
ferait douter de la box.

Le chemin est celui qui était demandé, et pour la raison qui était donnée :
  · LIRE (GetLoadConfig, frais — pas l'état en mémoire, la box a pu changer d'avis)
  · PATCHER (payloadWithoutLoad : les autres charges recopiées VERBATIM, champ par champ)
  · RÉÉCRIRE (SetLoadConfig, qui remplace l'ensemble)

Jamais une reconstruction depuis le modèle typé : un champ qu'une box plus récente porterait
et que l'app ne connaît pas serait perdu pour TOUTES les charges d'un coup, en silence. Les
rangs ne sont pas renumérotés — le trou laissé (1,2,3,5) ne change l'ordre de personne,
alors qu'une renumérotation réécrirait le rang de charges que personne n'a touchées.

Rien à moitié écrit : un refus laisse la configuration locale intacte (aucune adoption avant
confirmation) et la box refuse l'ensemble ou l'accepte en entier. Le geste est refusé tant
qu'un brouillon est en cours — sinon la suppression emporterait des modifications non
enregistrées. La confirmation annonce ce que l'utilisateur ne peut pas deviner : retirer une
charge réécrit toutes les autres.

── 2. Le mode de recharge, un seul lieu d'écriture ───────────────────────────

Les trois boutons quittent la tuile des favoris pour l'écran « Configurer » de la borne :
c'est l'écran de mise en service, celui où on découvre qu'une borne neuve est en
ChargingModeNormal. Deux endroits qui écrivent SetChargingInfo seraient deux comportements
qui divergent.

La tuile des favoris garde la LECTURE et l'avertissement « hors arbitrage » : c'est le
client qui ouvre ses favoris, et il doit voir que sa borne n'est pas pilotée même s'il doit
aller ailleurs pour le corriger.

⚠️ Bug latent trouvé en déplaçant : setChargingInfo écrivait toujours sur la PREMIÈRE borne
configurée (_findEvChargerId). Sur le banc qui en porte trois, régler la V2C depuis son
propre écran aurait réglé la wallbox simulée. La borne est maintenant visée explicitement,
et evChargerFor() réconcilie les deux graphies d'identifiant (§9 du contrat de données).

181 tests (+12), 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 14:36:50 +02:00

284 lines
13 KiB
Dart

/// Les bornes **en configuration** — l'invariant `loads[] ⊆ GetLoadConfig`, rétabli.
///
/// 3g-1 avait rompu l'inclusion sans le signaler : les bornes entraient dans `loads[]` de
/// `GetLoadTelemetry` sans figurer dans aucune `GetLoadConfig`. C'était la charge fantôme
/// que le lot B-bis avait supprimée, revenue par l'autre bout — et l'app s'appuie sur cet
/// invariant pour résoudre libellé, domaine et rang de **toute** charge publiée.
///
/// Depuis `+etm23`, toute borne détectée reçoit d'office une entrée `LoadConfig`. La
/// fixture est le dump réel de `.75` en `+etm24` : quatre entrées là où il y en avait deux.
///
/// Ce que ces tests protègent, et qui n'est pas évident :
///
/// 1. une borne **ne porte aucune charge utile de mécanisme**, et ne doit donc pas tomber
/// dans le repli « modulable » — l'écran lui proposerait une puissance nominale et des
/// paliers qui n'existent pas pour elle ;
/// 2. l'aller-retour verbatim reste **neutre** avec ces entrées (vérifié aussi sur machine
/// le 2026-08-27, verdict « IDENTIQUE ») ;
/// 3. le glissement les traite **sans cas particulier** — c'est tout l'intérêt d'avoir une
/// seule liste ;
/// 4. le rang par défaut **dégénère en égalité**, et l'ordre affiché avant réglage est un
/// artefact qu'il faut nommer.
library;
import 'dart:convert';
import 'dart:io';
import 'package:etm_powersync_app/models/load_config_entry.dart';
import 'package:etm_powersync_app/services/load_mechanism_switch.dart';
import 'package:etm_powersync_app/services/load_priority.dart';
import 'package:flutter_test/flutter_test.dart';
List<LoadConfigEntry> _banc() {
final raw = File('test/fixtures/loadconfig_hems75_evcharger.json').readAsStringSync();
final list = (jsonDecode(raw) as Map<String, dynamic>)['loadConfigs'] as List;
return [
for (final m in list.whereType<Map>()) LoadConfigEntry(Map<String, dynamic>.from(m)),
];
}
/// Les charges utiles BRUTES du banc — pas les entrées typées : c'est justement ce que la
/// suppression doit préserver.
List<Map<String, dynamic>> _bancRaw() {
final raw = File('test/fixtures/loadconfig_hems75_v2c.json').readAsStringSync();
return ((jsonDecode(raw) as Map<String, dynamic>)['loadConfigs'] as List)
.whereType<Map>()
.map((m) => Map<String, dynamic>.from(m))
.toList();
}
void main() {
group('3g-2 — les bornes ont une entrée de configuration', () {
test('le dump du banc porte quatre charges, dont deux bornes', () {
final e = _banc();
expect(e.length, 4);
final bornes = e.where((x) => x.isEvCharger).toList();
expect(bornes.map((b) => b.label),
containsAll(['Simulated wallbox', 'Terra AC Charger (TCP)']));
for (final b in bornes) {
expect(b.adapter, 'evcharger');
expect(b.mode, 'dynamic');
expect(b.domain, 'ev');
expect(b.enabled, isTrue);
}
});
test('une borne ne tombe PAS dans le repli « modulable »', () {
// C'était le cas avant : `isVariableLoad` étant défini par défaut, l'écran de
// mécanisme offrait à une borne une puissance nominale et des paliers — des
// réglages qui n'existent pas pour elle, et dont l'écriture partirait quand même.
final borne = _banc().firstWhere((x) => x.isEvCharger);
expect(borne.isVariableLoad, isFalse);
expect(borne.isRelayRouter, isFalse);
expect(borne.isSgReady, isFalse);
// Et son adaptateur n'est pas dans la liste des mécanismes proposables : proposer
// une bascule reviendrait à offrir de transformer une borne en routeur de relais.
expect(kMecanismes, isNot(contains('evcharger')));
});
test('elle ne porte AUCUNE charge utile de mécanisme', () {
// Délibéré côté plugin : les limites d'une borne viennent du Thing et changent avec
// le véhicule branché. Les déclarer ici en ferait une seconde source de vérité.
final borne = _banc().firstWhere((x) => x.isEvCharger);
expect(borne.relays, isEmpty);
expect(borne.sgReadyStates, isEmpty);
expect(borne.powerLevelsInt, isEmpty);
expect(borne.maxPowerW, 0);
expect(borne.minOnS, 0);
expect(borne.minOffS, 0);
});
test('son id EST le ThingId de la borne — donc elle revendique son Thing', () {
// Ce n'est pas une commodité : c'est ce qui interdit de déclarer la même borne à la
// fois comme evcharger et comme charge pilotée. Deux commandeurs sur un organe,
// exactement ce que LM-201 refuse.
final borne = _banc().firstWhere((x) => x.isEvCharger);
expect(borne.claimedThingIds, {normalizeThingId(borne.id)});
final doublon = LoadConfigEntry({
...borne.raw,
'id': borne.id,
'label': 'la même borne, déclarée deux fois',
});
expect(validateSet([borne, doublon])?.kind, 'duplicateId');
});
test('l\'aller-retour verbatim est neutre — rien n\'est amputé', () {
// `patched()` sans argument doit rendre la map d'origine à l'identique : c'est ce
// qui préserve les charges utiles que l'app ne modélise pas.
for (final e in _banc()) {
expect(e.patched(), e.raw, reason: 'aller-retour non neutre sur ${e.id}');
}
});
test('le glissement traite les bornes sans cas particulier', () {
final groupes = groupByDomain(_banc());
expect(groupes.isContiguous, isTrue,
reason: 'les deux bornes doivent former une plage continue dans le domaine ev');
final ev = groupes.groups.firstWhere((g) => g.domain == 'ev');
expect(ev.loads.length, 2);
// Réaplatir renumérote 1..N sur TOUTE la liste, bornes comprises, et ne touche que
// `priority`. Aucune branche ne regarde l'adaptateur.
final charge = flattenToPayload(groupes.groups);
expect(charge.length, 4);
expect([for (final m in charge) m['priority']], [1, 2, 3, 4]);
for (final m in charge) {
final avant = _banc().firstWhere((e) => e.id == m['id']);
for (final k in avant.raw.keys) {
if (k == 'priority') continue;
expect(m[k], avant.raw[k], reason: 'clé $k altérée sur ${m['id']}');
}
}
});
test('inverser une borne et une charge pilotée est un échange ordinaire', () {
final groupes = groupByDomain(_banc()).groups;
final inverse = [...groupes.reversed];
final charge = flattenToPayload(inverse);
// Les domaines ont permuté, les rangs sont refaits, et rien d'autre n'a bougé.
expect([for (final m in charge) m['priority']], [1, 2, 3, 4]);
expect(charge.first['domain'], groupes.last.domain);
});
});
group('supprimer une charge — le seul geste destructeur de l\'app', () {
test('les AUTRES charges sont recopiées verbatim, champ par champ', () {
// `SetLoadConfig` remplace l'ensemble : supprimer une charge, c'est réécrire toutes
// les autres. Les reconstruire depuis le modèle typé perdrait, en silence et pour
// toutes, un champ qu'une box plus récente porterait.
final frais = _bancRaw();
final reste = payloadWithoutLoad(frais, 'chauffe-eau')!;
expect(reste.length, frais.length - 1);
expect(reste.any((m) => m['id'] == 'chauffe-eau'), isFalse);
for (final avant in frais.where((m) => m['id'] != 'chauffe-eau')) {
final apres = reste.firstWhere((m) => m['id'] == avant['id']);
for (final k in avant.keys) {
expect(apres[k], avant[k], reason: 'clé $k altérée sur ${avant['id']}');
}
expect(apres.keys.length, avant.keys.length,
reason: 'aucune clé ajoutée sur ${avant['id']}');
}
});
test('les rangs ne sont PAS renumérotés — le trou est sans effet', () {
// Renuméroter réécrirait le rang de charges que personne n'a touchées ; le tri est
// sur `priority`, un trou ne change l'ordre de personne.
final frais = _bancRaw();
final avant = {
for (final m in frais)
if (m['id'] != 'pac-terrain') m['id']: m['priority']
};
final reste = payloadWithoutLoad(frais, 'pac-terrain')!;
for (final m in reste) {
expect(m['priority'], avant[m['id']]);
}
});
test('une charge absente rend NULL, pas la liste inchangée', () {
// « Rien à supprimer » et « supprimé » ne doivent pas se ressembler côté appelant :
// une liste inchangée serait réécrite pour rien, sur la foi d'un succès.
expect(payloadWithoutLoad(_bancRaw(), 'charge-qui-nexiste-pas'), isNull);
});
});
group('3g-2 — le rang par défaut d\'une borne est un ARTEFACT', () {
test('trois charges du banc partagent le rang 1', () {
// À la création, le plugin donne « le plus petit rang existant moins un, borné à
// 1 ». Le chauffe-eau occupant déjà le rang 1, l'intention « en tête » dégénère.
final exAequo = tiedByPriority(_banc());
expect(exAequo.length, 3);
expect(exAequo.map((e) => e.priority).toSet(), {1});
expect(exAequo.where((e) => e.isEvCharger).length, 2);
});
test('l\'ordre reste reproductible — mais reproductible n\'est pas choisi', () {
// Le tri est total : l'id départage, donc l'écran ne bouge pas d'une lecture à
// l'autre. C'est exactement ce qui rend l'artefact crédible, et donc dangereux à
// taire.
final a = groupByDomain(_banc()).flatOrder.map((e) => e.id).toList();
final b = groupByDomain(_banc().reversed.toList()).flatOrder.map((e) => e.id).toList();
expect(a, b);
});
test('des rangs distincts font taire la note — dès le premier vrai réglage', () {
final regle = [
for (final (i, e) in _banc().indexed) LoadConfigEntry(e.patched(priority: i + 1)),
];
expect(tiedByPriority(regle), isEmpty);
});
});
// ───────────────────────────────────────────────────────────────────────────
group('terrain 2026-08-28 — la V2C remplace la Terra AC', () {
List<LoadConfigEntry> banc() {
final raw =
File('test/fixtures/loadconfig_hems75_v2c.json').readAsStringSync();
final list = (jsonDecode(raw) as Map<String, dynamic>)['loadConfigs'] as List;
return [
for (final m in list.whereType<Map>())
LoadConfigEntry(Map<String, dynamic>.from(m)),
];
}
test('l\'entrée d\'une borne SURVIT à la suppression de son appareil', () {
// Le plugin crée d'office une entrée par borne détectée, et ne la retire pas quand
// le Thing s'en va. Deux jours après le remplacement physique, la Terra AC occupe
// encore un rang dans la liste de l'installateur.
final e = banc();
expect(e.length, 5);
final terra = e.firstWhere((x) => x.label.contains('Terra'));
expect(terra.isEvCharger, isTrue);
expect(terra.enabled, isTrue, reason: 'elle n\'est pas désactivée — elle est ORPHELINE');
expect(terra.priority, 4, reason: 'et elle consomme un rang');
});
test('la note de rangs ex æquo DOIT s\'afficher sur ce banc', () {
// C'est le cas réel qui justifie la note : la V2C, créée d'office, arrive au rang 1
// que le chauffe-eau occupe déjà. Personne n'a choisi l'ordre entre les deux.
final exAequo = tiedByPriority(banc());
expect(exAequo, isNotEmpty);
expect(exAequo.map((e) => e.label),
containsAll(['chauffe-eau', 'V2C Trydan']));
expect(exAequo.length, 2,
reason: 'deux charges au rang 1 — la Terra est seule au rang 4');
});
test('⚠️ le rang par défaut de la V2C CASSE la contiguïté des domaines', () {
// Constat de terrain, et il va plus loin que « l'ordre n'est pas choisi ».
//
// La V2C est créée au rang 1, que le chauffe-eau (ecs) occupe déjà. L'ordre à plat
// devient : ecs, ev, heating, ev, ev — le domaine `ev` est ENTRELACÉ avec heating.
//
// L'app sait le détecter, et c'est vital : regrouper silencieusement puis réaplatir
// changerait l'arbitrage réel au premier enregistrement, sans que personne ne l'ait
// demandé. Ce test fige le fait que le cas se produit VRAIMENT sur une installation
// ordinaire — il n'était jamais apparu sur le banc avant l'arrivée de la V2C.
final groupes = groupByDomain(banc());
expect(groupes.isContiguous, isFalse);
expect(groupes.interleavedDomains, contains('ev'));
final aPlat = groupes.flatOrder.map((e) => e.domain).toList();
expect(aPlat, ['ecs', 'ev', 'heating', 'ev', 'ev']);
final ev = groupes.groups.firstWhere((g) => g.domain == 'ev');
expect(ev.loads.length, 3, reason: 'V2C + wallbox simulée + Terra orpheline');
});
test('réaplatir un ordre entrelacé le CHANGE — d\'où l\'avertissement', () {
// La preuve chiffrée de ce que l'écran doit annoncer avant d'enregistrer : la PAC
// passe du rang 2 au rang 5. Ce n'est pas un détail d'affichage, c'est l'ordre de
// service du surplus qui bascule.
final groupes = groupByDomain(banc());
final avant = {for (final e in banc()) e.id: e.priority};
final apres = {
for (final m in flattenToPayload(groupes.groups)) m['id']: m['priority']
};
expect(avant['pac-terrain'], 2);
expect(apres['pac-terrain'], 5,
reason: 'la PAC recule de trois rangs par le seul fait de regrouper');
});
});
}