etm-powersync-app/test/evcharger_config_test.dart
Patrick Schurig 553a182e9b fix(config): l'ordre entrelacé se voit enfin, et un appareil disparu cesse d'être un défaut à lever
L'arrivée de la V2C sur .75 a révélé deux défauts que le banc ne pouvait pas montrer avant,
plus deux pièges de contrat à documenter.

1. ⚠️ isNotGrouped ÉTAIT CALCULÉ ET AFFICHÉ NULLE PART. GroupedOrder détecte depuis le
   premier jour qu'un ordre n'est pas groupé par domaine, le provider l'expose, et aucune
   vue ne le lisait — le cas ne s'était jamais produit, chaque domaine du banc ayant ses
   charges d'un seul tenant.

   Il se produit maintenant : la V2C, créée d'office au rang 1 que le chauffe-eau occupait
   déjà, donne l'ordre à plat « ecs · ev · heating · ev · ev ». Le domaine ev est coupé en
   deux par le chauffage.

   Ce n'était pas un détail d'affichage. L'écran groupe pour afficher, donc ce qu'on voit
   n'est plus l'ordre réel de la box — et le premier enregistrement réaplatit EN GROUPANT,
   ce qui change l'arbitrage sans que personne ne l'ait demandé. Chiffré par test sur la
   configuration réelle : la PAC passerait du rang 2 au rang 5. C'est exactement ce que le
   commentaire de GroupedOrder interdit depuis le début ; le garde-fou existait, il n'était
   pas branché.

2. « L'APPAREIL N'EXISTE PLUS » N'EST PAS UN DÉFAUT À LEVER. La Terra AC a été supprimée de
   nymea ; son entrée LoadConfig survit, occupe le rang 4, et s'affiche en THING_MISSING
   avec un bouton « Lever le défaut ». ClearLoadFault y répondrait EnergyErrorNoError sans
   rien changer, et le défaut reviendrait au cycle suivant — un bouton qui ne peut pas
   tenir sa promesse fait douter de la box au lieu de désigner la cause.

   Le bouton disparaît sur ce code, remplacé par ce qu'il faut faire : réinstaller
   l'appareil, ou retirer la charge. Et le libellé cesse de se lire « votre configuration
   est cassée » : le cas normal est un appareil remplacé, 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.

3. Deux pièges du Thing V2C, documentés là où quelqu'un serait tenté de les câbler :

   chargeEnergy est EXACT pendant la charge — sa croissance recoupe currentPower à mieux
   d'un pour cent — et REMIS À ZÉRO à l'arrêt de la charge, pas au débranchement. Mesuré :
   0,3456 kWh → 0 dès que charging passe à faux, 119 secondes avant que le câble ne bouge.
   Sur du pilotage par surplus il repartirait de zéro à chaque nuage, et après coup une
   nuit entière vaudrait 0 — soit « rien livré » affiché là où la vérité est « pas
   mesurable ». Le plugin publiera un sessionEnergy construit ; rien n'est bâti en
   attendant, et surtout aucune accumulation côté app.

   powerL1/L2/L3 sont des ampères mal nommés, renommage currentL* en cours côté plugin.
   Ni affichage, ni conversion : une conversion écrite ici survivrait au correctif et le
   contredirait.

Fixture ajoutée : la configuration réelle à cinq entrées, avec ses deux enseignements de
terrain — deux rangs à 1, et une entrée orpheline qui consomme un rang.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016vrTVifar2GN5rtUh89Wkq
2026-08-28 11:57:12 +02:00

233 lines
11 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)),
];
}
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('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');
});
});
}