Deux suites directes du geste réel du 2026-08-28 sur le banc.
── 1. Une vérification qui arrive trop tard ne prouve rien ───────────────────
Sur le banc, la suppression a été vérifiée à la main, APRÈS un autre geste : entre-temps un
« Enregistrer » avait regroupé et renuméroté (chauffe-eau 1 → 3). Il est alors devenu
impossible de dire laquelle des deux écritures avait déplacé les rangs. Le journal de la box
montrait deux persistances à deux minutes d'écart, rien de plus.
`removeLoad` relit donc la configuration IMMÉDIATEMENT après l'écriture — sans attendre
LoadConfigChanged, qui laisserait une fenêtre où un autre geste s'intercale — et compare
clé par clé ce qu'elle a envoyé à ce que la box porte (diffPayloads, fonction pure). Les
survivantes doivent être identiques, rangs compris : c'est le contrôle de l'aller-retour
neutre appliqué à une écriture qui RETIRE.
Le message le dit : « Relu sur la box : les autres sont identiques clé par clé, rangs
compris », ou l'écart en rouge, nommé champ par champ. Sur le seul geste de l'app qui
détruit de la configuration, « la box a dit oui » ne suffisait pas.
La comparaison est structurelle : relays, sgReady et powerLevels sont des listes et des
objets, que `==` compare par référence — l'alerte aurait crié à chaque suppression, donc
plus personne ne l'aurait lue.
── 2. Le sélecteur de compteur était plus restrictif que la box ─────────────
Il filtrait sur l'interface smartmeter. Le moteur n'en regarde AUCUNE : findConfiguredThing
puis hasState("currentPower") (attachMeasurements, LM-1101). Sur .75, la classe wallbox
déclare smartmeter, la classe trydan non — la V2C était donc impossible à désigner comme
son propre compteur, alors que la box l'aurait accepté et que c'est le seul moyen de voir
« mesuré » à côté de « alloué » sur sa carte.
Le sélecteur accepte maintenant, en plus des smartmeter, les Things d'une interface
élargie qui publient l'état exigé par le moteur — ici evcharger + currentPower. Il s'élargit
là où la box est plus large, pas partout : une borne qui ne publie aucune puissance reste
dehors, sans quoi on écrirait une configuration muette sans erreur.
⚠️ Ce n'est pas la fin de l'écart : la classe trydan devrait déclarer smartmeter, ce qui
suppose un CUMUL d'énergie qu'elle ne publie pas — même manque que celui qui a bloqué
LM-1009. Lot plugin noté au TODO avec cette raison.
── Banc ──────────────────────────────────────────────────────────────────────
Chauffe-eau remis au rang 1 (lire → patcher → réécrire, seul `priority` touché, relecture
vérifiée : la box porte exactement ce qui a été envoyé). Ordre courant : chauffe-eau,
V2C, wallbox simulée, PAC.
188 tests (+7), flutter analyze inchangé à 27 remarques.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f
325 lines
15 KiB
Dart
325 lines
15 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('la relecture confirme quand RIEN d\'autre n\'a bougé', () {
|
|
final envoye = payloadWithoutLoad(_bancRaw(), 'chauffe-eau')!;
|
|
// La box rend ce qu'on lui a donné, dans un autre ordre : l'ordre de la liste n'est
|
|
// pas une différence, la comparaison est par identifiant.
|
|
final relu = envoye.reversed.map((m) => {...m}).toList();
|
|
expect(diffPayloads(envoye, relu), isEmpty);
|
|
});
|
|
|
|
test('un RANG changé au passage se voit — c\'est le cas du 2026-08-28', () {
|
|
// Une suppression qui renumérote n'est plus un nettoyage, c'est un geste
|
|
// d'arbitrage. Le 28, deux écritures à deux minutes d'écart avaient rendu
|
|
// impossible de dire laquelle avait déplacé le chauffe-eau du rang 1 au rang 3.
|
|
final envoye = payloadWithoutLoad(_bancRaw(), '{88160e45-7df2-48cc-9639-f0d15ae6e382}')!;
|
|
final relu = [
|
|
for (final m in envoye)
|
|
if (m['id'] == 'chauffe-eau') {...m, 'priority': 3} else {...m},
|
|
];
|
|
final ecarts = diffPayloads(envoye, relu);
|
|
expect(ecarts.length, 1);
|
|
expect(ecarts.single, contains('priority'));
|
|
expect(ecarts.single, contains('1 → 3'));
|
|
});
|
|
|
|
test('une charge qui disparaît ou apparaît en plus se voit aussi', () {
|
|
final envoye = payloadWithoutLoad(_bancRaw(), 'chauffe-eau')!;
|
|
expect(diffPayloads(envoye, envoye.sublist(1)).single, contains('a disparu'));
|
|
expect(
|
|
diffPayloads(envoye.sublist(1), envoye).single, contains('est apparue'));
|
|
});
|
|
|
|
test('les charges utiles imbriquées se comparent par VALEUR', () {
|
|
// `relays` et `sgReady` sont des listes et des objets : `==` les comparerait par
|
|
// référence, et toute relecture paraîtrait alors différente — l'alerte crierait à
|
|
// chaque suppression, donc plus personne ne la lirait.
|
|
final envoye = payloadWithoutLoad(_bancRaw(), 'pac-terrain')!;
|
|
final relu = [
|
|
for (final m in envoye) jsonDecode(jsonEncode(m)) as Map<String, dynamic>
|
|
];
|
|
expect(diffPayloads(envoye, relu), isEmpty);
|
|
});
|
|
|
|
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');
|
|
});
|
|
});
|
|
}
|