etm-powersync-app/test/evcharger_config_test.dart
Patrick Schurig a44d64d6c2 feat(3g-2): les bornes sont des charges configurées — une seule liste, un seul rang
+etm23 rend à chaque borne détectée une entrée GetLoadConfig, et l'invariant
loads[] ⊆ GetLoadConfig est rétabli. Sur .75 : 2 entrées → 4. L'app s'appuyait déjà sur cet
invariant pour résoudre libellé, domaine et rang de toute charge publiée.

Vérifié sur machine avant d'écrire quoi que ce soit :
- l'aller-retour verbatim reste NEUTRE avec les quatre entrées (loadconfig_roundtrip.dart,
  verdict IDENTIQUE) ;
- une entrée evcharger porte id/label/domain/priority/enabled, mode dynamic, et AUCUNE
  charge utile de mécanisme.

Trois défauts que ce contrat révèle côté app :

1. isVariableLoad était défini par défaut (« ni relais ni sg-ready »), donc une borne y
   tombait. L'écran de mécanisme lui proposait une puissance nominale, des paliers et des
   temporisations — des réglages qui n'existent pas pour elle et dont l'écriture partirait
   quand même. Pire, le sélecteur offrait de la basculer en routeur de relais : une bascule
   que basculerMecanisme() sait construire et qu'aucun écran ne sait défaire. Une borne a
   maintenant sa section, en lecture seule, qui dit pourquoi il n'y a rien à régler.

2. BorneSection existait pour rattraper des bornes qui n'apparaissaient nulle part. Elles
   apparaissent maintenant dans leur domaine : la section les afficherait DEUX fois, en
   affirmant qu'elles n'ont « ni rang ni domaine » et qu'elles ne passent pas par
   SetLoadConfig. Trois affirmations devenues fausses. Retirée ; son contenu de runtime
   vit désormais dans l'écran de mécanisme, où il a un sens.

3. Le rang par défaut d'une borne est un ARTEFACT, et l'écran le disait comme un réglage.
   À 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 en égalité —
   trois charges à 1 sur le banc. Le tri reste total (l'id départage), donc l'ordre est
   reproductible, et c'est précisément ce qui le rend crédible : l'installateur lit un
   classement plausible et n'y touche pas, validant un ordre que personne n'a posé. Une
   note le nomme, et disparaît dès le premier vrai réglage.

Le glissement, lui, n'a demandé aucun cas particulier — c'était tout l'intérêt d'avoir une
seule liste. Vérifié par test, y compris l'inversion borne / charge pilotée.

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

162 lines
7.4 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);
});
});
}