Manque trouvé en usage, pas en test : les flèches déplacent une charge DANS son bloc, le glissement déplace le bloc ENTIER. Rien ne sortait une charge de son groupe. Une borne regroupée avec les autres devenait donc prisonnière du bloc — et depuis que « Regrouper par domaine » existe, ce cas se produit d'un simple geste. Le bouton la renvoie en queue, seule dans sa plage. C'est la position qu'une borne détectée reçoit d'office depuis +etm27 (1 + max), donc un repère connu — et une fois seule, le glissement de plage la porte partout ailleurs. Ça rend la liberté de placement sans inventer un glisser-déposer entre blocs, qui demanderait de viser un point d'insertion au pixel près sur un téléphone. Brouillon seulement : rien ne part avant save(). Masqué quand la charge y est déjà — un bouton qui ne fait rien use la confiance dans ceux qui font quelque chose. Le contrôle porte sur l'état AVANT retrait : après, la plage d'origine a pu disparaître et la comparaison ne veut plus rien dire (première version fausse, prise par son propre test). ⚠️ Et une assertion Flutter trouvée par les tests de glissement, dont la cause est à trois widgets de son symptôme : `TextButton.styleFrom(textStyle:)` porte `inherit: true` là où le style du thème porte `inherit: false`, et Flutter refuse d'interpoler les deux pendant une animation — « Failed to interpolate TextStyles with different inherit values ». Le style va donc sur le LIBELLÉ, pas dans le ButtonStyle. Les deux boutons posés hier (« Regrouper par domaine », « Ce rang me convient ») portaient la même bombe à retardement : corrigés aussi. 231 tests (+5), flutter analyze inchangé à 27 remarques. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f
275 lines
12 KiB
Dart
275 lines
12 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/providers/load_config_provider.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('l\'app renvoie la charge utile TELLE QUELLE — plus aucun filtre', () {
|
|
// Le nettoyage de `"auto"` est retiré le 2026-08-29 : l'écho verbatim inchangé passe
|
|
// (`reconcileRankOrigin()` l'accepte quand le persisté le porte déjà). Filtrer encore
|
|
// effacerait une marque que la box aurait gardée — et le badge s'éteindrait sur une
|
|
// borne que personne n'a classée, au premier geste voisin.
|
|
final frais = [
|
|
{'id': 'a', 'label': 'A', 'priority': 1, 'rankOrigin': 'auto'},
|
|
{'id': 'b', 'label': 'B', 'priority': 2, 'rankOrigin': 'user'},
|
|
];
|
|
final renomme = [
|
|
for (final m in frais)
|
|
if (m['id'] == 'b') {...m, 'label': 'B bis'} else Map<String, dynamic>.from(m)
|
|
];
|
|
expect(renomme[0]['rankOrigin'], 'auto',
|
|
reason: 'la marque du moteur traverse un geste qui ne la concerne pas');
|
|
expect(diffPayloads(frais, renomme).single, contains('label'));
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
});
|
|
|
|
group('sortir une charge de sa plage — « Placer en dernier »', () {
|
|
/// L'ordre du banc au 2026-08-29, une fois REGROUPÉ : la wallbox 2 est prisonnière du
|
|
/// bloc `ev`, et plus rien ne l'en sort — c'est le manque que ce geste comble.
|
|
List<Map<String, dynamic>> groupe() => [
|
|
{'id': 'chauffe-eau', 'label': 'chauffe-eau', 'priority': 1, 'domain': 'ecs'},
|
|
{'id': 'v2c', 'label': 'V2C', 'priority': 2, 'domain': 'ev'},
|
|
{'id': 'sim1', 'label': 'wallbox', 'priority': 3, 'domain': 'ev'},
|
|
{'id': 'sim2', 'label': 'wallbox 2', 'priority': 4, 'domain': 'ev'},
|
|
{'id': 'pac', 'label': 'PAC', 'priority': 5, 'domain': 'heating'},
|
|
];
|
|
|
|
test('la charge quitte sa plage et rejoint la queue', () {
|
|
final p = LoadConfigProvider()..adoptForTest(groupe());
|
|
p.moveToTail('sim2');
|
|
|
|
final payload = flattenToPayload(p.groups);
|
|
expect([for (final m in payload) m['id']],
|
|
['chauffe-eau', 'v2c', 'sim1', 'pac', 'sim2']);
|
|
expect(payload.last['priority'], 5);
|
|
// Elle est SEULE dans sa plage : le glissement de plage la portera partout ailleurs.
|
|
expect(p.groups.last.loads.length, 1);
|
|
expect(p.groups.last.loads.single.id, 'sim2');
|
|
});
|
|
|
|
test('rien ne part : c\'est un brouillon', () {
|
|
final p = LoadConfigProvider()..adoptForTest(groupe());
|
|
p.moveToTail('sim2');
|
|
expect(p.hasPendingChanges, isTrue);
|
|
// La configuration adoptée, elle, n'a pas bougé.
|
|
expect(p.entries.firstWhere((e) => e.id == 'sim2').priority, 4);
|
|
});
|
|
|
|
test('déjà en queue : aucun brouillon — un bouton qui ne fait rien use la confiance',
|
|
() {
|
|
final p = LoadConfigProvider()..adoptForTest(groupe());
|
|
p.moveToTail('pac');
|
|
expect(p.hasPendingChanges, isFalse);
|
|
});
|
|
|
|
test('les rangs restent distincts, et rien d\'autre ne bouge', () {
|
|
final p = LoadConfigProvider()..adoptForTest(groupe());
|
|
p.moveToTail('v2c');
|
|
|
|
final payload = flattenToPayload(p.groups);
|
|
final rangs = [for (final m in payload) m['priority']];
|
|
expect(rangs.toSet().length, rangs.length, reason: '+etm27 refuse les doublons');
|
|
// Les autres gardent leur ordre relatif : sortir une charge n'est pas reclasser.
|
|
expect([for (final m in payload) m['id']],
|
|
['chauffe-eau', 'sim1', 'sim2', 'pac', 'v2c']);
|
|
});
|
|
|
|
test('une charge seule dans sa plage RESTE sortable ailleurs qu\'en queue', () {
|
|
// Cas du banc avant regroupement : sim2 est déjà seule et déjà dernière. Le geste
|
|
// ne s'offre pas — mais le glissement de plage, lui, reste disponible.
|
|
final p = LoadConfigProvider()..adoptForTest([
|
|
{'id': 'a', 'label': 'A', 'priority': 1, 'domain': 'ecs'},
|
|
{'id': 'b', 'label': 'B', 'priority': 2, 'domain': 'ev'},
|
|
]);
|
|
p.moveToTail('b');
|
|
expect(p.hasPendingChanges, isFalse);
|
|
p.reorderDomains(1, 0);
|
|
expect(p.hasPendingChanges, isTrue);
|
|
expect([for (final m in flattenToPayload(p.groups)) m['id']], ['b', 'a']);
|
|
});
|
|
});
|
|
}
|