`"user"` n'est pas « j'ai vu le badge ». C'est « ce rang me va, je l'assume » : un jugement porté sur l'ordre de service, et la seule voie pour éteindre le badge SANS changer l'arbitrage. Les trois conséquences sont dans le code, parce qu'elles sont faciles à perdre. 1. LE GESTE EST EXPLICITE. Un bouton sur le badge, appelé de là et de nulle part ailleurs. Jamais une écriture au passage, jamais l'effet de bord d'un autre enregistrement : écrire "user" parce que l'utilisateur a touché à autre chose affirmerait un jugement qu'il n'a pas porté — le reproche exact qu'on fait à un "auto" fabriqué côté client. 2. IL NE S'ANNULE PAS TOUT SEUL. Une fois posé, seul le moteur le lève, quand un rang différent arrive pour cet id. L'app ne le retire jamais. 3. IL NE REMPLACE PAS LE DÉPLACEMENT, et le libellé le dit : « Adopter ce rang ne le change pas. Pour en changer, glissez la charge. » Sans cette phrase, un installateur valide un ordre qu'il voulait changer — et n'y reviendra pas, puisque le badge s'est éteint. L'écriture emprunte le chemin de la suppression, pour la même raison : SetLoadConfig remplace tout l'ensemble. Lire (frais) → patcher le SEUL rankOrigin de la SEULE charge visée → réécrire verbatim → relire et comparer clé par clé. Le rang ne bouge pas : « ce rang me va » ne peut pas porter sur un rang qu'on vient de changer. Garde-fou des rangs à égalité inclus, et refus si un brouillon est en cours. Vérifié au passage, comme demandé : sansRankOriginAuto() laisse passer "user" — le confondre avec "auto" aurait rendu le bouton silencieusement inopérant. Test dédié, en plus de celui qui existait. Quatre tests de plus : le bouton est là sur "auto" avec sa phrase, absent sur "user" et sur champ vide, et la charge utile d'une adoption ne diffère que par rankOrigin. ⚠️ Non vérifiable sur `.75` : les deux bornes y sont antérieures à `+etm27`, donc aucun "auto" n'y est observable. À reprendre au premier appairage réel d'une borne. 226 tests (+4), flutter analyze inchangé à 27 remarques. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f
236 lines
10 KiB
Dart
236 lines
10 KiB
Dart
/// **`1.15.2+etm27` refuse en bloc un ensemble où deux charges partagent un rang.**
|
|
///
|
|
/// Le motif du refus ne franchit pas la frontière RPC : le symptôme au doigt serait « mon
|
|
/// glissement ne s'enregistre pas », sans cause visible. Ces tests vérifient les trois
|
|
/// choses que ça exige de l'app, **avant** le déploiement :
|
|
///
|
|
/// 1. le réaplatissage produit toujours des rangs distincts, quel que soit l'état d'où il
|
|
/// part — y compris un ordre entrelacé ou déjà à égalité ;
|
|
/// 2. rien ne part à la box tant que le brouillon n'est pas cohérent — le miroir client
|
|
/// voit l'égalité avant l'envoi ;
|
|
/// 3. le chemin de suppression, qui réécrit VERBATIM, refuse plutôt que de transporter une
|
|
/// égalité que la box rejettera.
|
|
library;
|
|
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
|
|
import 'package:etm_powersync_app/models/load_config_entry.dart';
|
|
import 'package:etm_powersync_app/services/load_priority.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
|
|
List<LoadConfigEntry> _banc([String fixture = 'loadconfig_hems75_v2c.json']) {
|
|
final raw = jsonDecode(File('test/fixtures/$fixture').readAsStringSync());
|
|
return [
|
|
for (final m in ((raw as Map<String, dynamic>)['loadConfigs'] as List))
|
|
LoadConfigEntry(Map<String, dynamic>.from(m as Map)),
|
|
];
|
|
}
|
|
|
|
/// Les rangs d'une charge utile prête à partir.
|
|
List<int> _rangs(List<Map<String, dynamic>> payload) =>
|
|
[for (final m in payload) m['priority'] as int];
|
|
|
|
void main() {
|
|
group('le réaplatissage rend TOUJOURS des rangs distincts', () {
|
|
test('sur le banc, dont l\'ordre porte deux charges au rang 1', () {
|
|
// C'est l'état réel du 2026-08-28 : la V2C créée d'office au rang 1 que le
|
|
// chauffe-eau occupait déjà. Sous +etm27, réécrire cet ensemble tel quel serait
|
|
// refusé — le réaplatissage doit donc le réparer, et il le fait.
|
|
final entrees = _banc();
|
|
expect(tiedByPriority(entrees), isNotEmpty, reason: 'prémisse du test');
|
|
|
|
final rangs = _rangs(flattenToPayload(groupByDomain(entrees).groups));
|
|
expect(rangs.toSet().length, rangs.length);
|
|
expect(rangs, List.generate(entrees.length, (i) => i + 1));
|
|
});
|
|
|
|
test('sur un ordre entrelacé', () {
|
|
final rangs = _rangs(
|
|
flattenToPayload(groupByDomain(_banc('loadconfig_interleaved.json')).groups));
|
|
expect(rangs.toSet().length, rangs.length);
|
|
});
|
|
|
|
test('après CHAQUE déplacement, pas seulement à la fin', () {
|
|
// Le glissement produit des états intermédiaires : ce qui compte est qu'aucun d'eux
|
|
// ne puisse partir avec une égalité, pas seulement l'état final.
|
|
var groupes = groupByDomain(_banc()).groups;
|
|
for (var i = 0; i < groupes.length; i++) {
|
|
for (var j = 0; j < groupes.length; j++) {
|
|
final permute = [...groupes];
|
|
permute.insert(j, permute.removeAt(i));
|
|
final rangs = _rangs(flattenToPayload(permute));
|
|
expect(rangs.toSet().length, rangs.length,
|
|
reason: 'domaine $i → $j produit une égalité');
|
|
}
|
|
}
|
|
// Et à l'intérieur d'un domaine.
|
|
groupes = groupByDomain(_banc()).groups;
|
|
final ev = groupes.indexWhere((g) => g.domain == 'ev');
|
|
if (ev >= 0 && groupes[ev].loads.length > 1) {
|
|
final charges = [...groupes[ev].loads];
|
|
charges.insert(0, charges.removeLast());
|
|
final refait = [
|
|
for (final (i, g) in groupes.indexed)
|
|
if (i == ev) DomainGroup(domain: g.domain, loads: charges) else g
|
|
];
|
|
final rangs = _rangs(flattenToPayload(refait));
|
|
expect(rangs.toSet().length, rangs.length);
|
|
}
|
|
});
|
|
});
|
|
|
|
group('rien ne part si le brouillon n\'est pas cohérent', () {
|
|
test('le miroir client VOIT l\'égalité de rang', () {
|
|
// Sans ce contrôle, la box refuserait tout l'ensemble sans dire pourquoi.
|
|
final conflit = validateSet(_banc());
|
|
expect(conflit, isNotNull);
|
|
expect(conflit!.kind, 'tiedPriority');
|
|
expect(conflit.rank, 1);
|
|
expect([conflit.firstLabel, conflit.secondLabel],
|
|
containsAll(['chauffe-eau', 'V2C Trydan']));
|
|
});
|
|
|
|
test('un ensemble réaplati passe le miroir', () {
|
|
// Le chemin normal de l'écran : on réaplatit, donc on n'envoie jamais d'égalité.
|
|
final aplati = [
|
|
for (final m in flattenToPayload(groupByDomain(_banc()).groups))
|
|
LoadConfigEntry(m)
|
|
];
|
|
expect(validateSet(aplati), isNull);
|
|
});
|
|
|
|
test('l\'égalité est vue AVANT les autres conflits qu\'elle masquerait', () {
|
|
// Une charge désactivée compte aussi : le moteur ne l'exclut pas de cette règle-ci,
|
|
// contrairement à la revendication de Thing.
|
|
final entrees = [
|
|
LoadConfigEntry(const {
|
|
'id': 'a',
|
|
'label': 'A',
|
|
'priority': 2,
|
|
'adapter': 'evcharger',
|
|
'enabled': true
|
|
}),
|
|
LoadConfigEntry(const {
|
|
'id': 'b',
|
|
'label': 'B',
|
|
'priority': 2,
|
|
'adapter': 'evcharger',
|
|
'enabled': false
|
|
}),
|
|
];
|
|
expect(validateSet(entrees)?.kind, 'tiedPriority');
|
|
});
|
|
});
|
|
|
|
group('la suppression ne transporte pas une égalité', () {
|
|
test('retirer une charge NE RÉPARE PAS les rangs — et c\'est voulu', () {
|
|
// Le chemin de suppression réécrit verbatim : les rangs survivent tels quels. C'est
|
|
// ce qui rend le garde-fou nécessaire, et c'est aussi ce qui empêche un geste de
|
|
// nettoyage de devenir un geste d'arbitrage.
|
|
final frais = [
|
|
for (final e in _banc()) Map<String, dynamic>.from(e.raw)
|
|
];
|
|
final reste = payloadWithoutLoad(
|
|
frais, '{88160e45-7df2-48cc-9639-f0d15ae6e382}')!;
|
|
final apres = [for (final m in reste) LoadConfigEntry(m)];
|
|
expect(tiedByPriority(apres), isNotEmpty,
|
|
reason: 'l\'égalité chauffe-eau / V2C survit à la suppression — d\'où le refus');
|
|
});
|
|
|
|
test('quand la charge retirée PORTAIT l\'égalité, la suppression la résout', () {
|
|
final frais = [
|
|
{'id': 'a', 'label': 'A', 'priority': 1, 'adapter': 'relay-router'},
|
|
{'id': 'b', 'label': 'B', 'priority': 1, 'adapter': 'evcharger'},
|
|
{'id': 'c', 'label': 'C', 'priority': 2, 'adapter': 'sg-ready'},
|
|
];
|
|
final reste = payloadWithoutLoad(frais, 'b')!;
|
|
expect(tiedByPriority([for (final m in reste) LoadConfigEntry(m)]), isEmpty);
|
|
});
|
|
});
|
|
|
|
group('rankOrigin — ce que l\'app a le droit d\'écrire', () {
|
|
test('`""` se lit comme « rien d\'affirmé », pas comme une valeur', () {
|
|
// Depuis +etm27 la clé sort TOUJOURS, vide quand rien n'est déclaré — même
|
|
// convention que `domain` et `meterThingId`. Relevé sur `.75` le 2026-08-29 : les
|
|
// quatre charges du banc portent `rankOrigin: ""`. La traiter comme une valeur
|
|
// afficherait un badge sur toute l'installation.
|
|
final e = LoadConfigEntry(const {'id': 'a', 'rankOrigin': ''});
|
|
expect(e.rankOrigin, isNull);
|
|
expect(e.rangPoseDOffice, isFalse);
|
|
});
|
|
|
|
test('`"auto"` se lit, et il est le SEUL à allumer le badge', () {
|
|
expect(LoadConfigEntry(const {'id': 'a', 'rankOrigin': 'auto'}).rangPoseDOffice,
|
|
isTrue);
|
|
expect(LoadConfigEntry(const {'id': 'a', 'rankOrigin': 'user'}).rangPoseDOffice,
|
|
isFalse);
|
|
expect(LoadConfigEntry(const {'id': 'a'}).rangPoseDOffice, isFalse);
|
|
});
|
|
|
|
test('⚠️ ce qui PART ne porte jamais `"auto"` — la box le refuserait en bloc',
|
|
() {
|
|
// Les chemins d'écriture recopient verbatim : sans ce nettoyage, un simple renommage
|
|
// renverrait la marque que le moteur a posée, et l'ensemble serait rejeté
|
|
// (EnergyErrorInvalidParameter) dès qu'une borne auto-provisionnée existe.
|
|
final envoye = sansRankOriginAuto([
|
|
{'id': 'a', 'priority': 1, 'rankOrigin': 'auto'},
|
|
{'id': 'b', 'priority': 2, 'rankOrigin': 'user'},
|
|
{'id': 'c', 'priority': 3, 'rankOrigin': ''},
|
|
{'id': 'd', 'priority': 4},
|
|
]);
|
|
|
|
expect(envoye[0]['rankOrigin'], '', reason: 'auto → effacé');
|
|
expect(envoye[1]['rankOrigin'], 'user', reason: 'user est permis, on n\'y touche pas');
|
|
expect(envoye[2]['rankOrigin'], '');
|
|
expect(envoye[3].containsKey('rankOrigin'), isFalse,
|
|
reason: 'une clé absente le reste — on n\'en fabrique pas');
|
|
});
|
|
|
|
test('`"user"` PASSE le point de sortie — sinon le geste ne partirait jamais', () {
|
|
// Le filtre vise `"auto"`, que l'app n'a pas le droit d'écrire. `"user"` est
|
|
// précisément ce qu'elle a le droit d'écrire : le confondre avec `"auto"` rendrait
|
|
// le bouton « ce rang me convient » silencieusement inopérant.
|
|
final envoye = sansRankOriginAuto([
|
|
{'id': 'a', 'priority': 1, 'rankOrigin': 'user'},
|
|
]);
|
|
expect(envoye.single['rankOrigin'], 'user');
|
|
});
|
|
|
|
test('adopter n\'est pas classer : le rang ne bouge pas', () {
|
|
// La charge utile d'une adoption ne change QUE `rankOrigin`. Un geste qui déplacerait
|
|
// la charge en même temps rendrait l'affirmation fausse — « ce rang me va » ne peut
|
|
// pas porter sur un rang qu'on vient de changer.
|
|
final avant = {
|
|
'id': 'a',
|
|
'label': 'A',
|
|
'priority': 3,
|
|
'rankOrigin': 'auto',
|
|
'domain': 'ev',
|
|
};
|
|
final apres = {...avant, 'rankOrigin': 'user'};
|
|
expect(apres['priority'], avant['priority']);
|
|
expect(diffPayloads([avant], [apres]).single, contains('rankOrigin'));
|
|
expect(diffPayloads([avant], [apres]).length, 1);
|
|
});
|
|
|
|
test('le nettoyage ne touche à RIEN d\'autre', () {
|
|
final avant = {
|
|
'id': 'a',
|
|
'label': 'A',
|
|
'priority': 1,
|
|
'rankOrigin': 'auto',
|
|
'relays': [
|
|
{'thingId': '{x}', 'powerW': 500}
|
|
],
|
|
};
|
|
final apres = sansRankOriginAuto([avant]).single;
|
|
for (final k in avant.keys) {
|
|
if (k == 'rankOrigin') continue;
|
|
expect(apres[k], avant[k], reason: 'clé $k altérée');
|
|
}
|
|
expect(apres.keys.length, avant.keys.length);
|
|
});
|
|
});
|
|
}
|