etm-powersync-app/test/load_priority_test.dart
Patrick Schurig 2c193077f7 fix(config): +etm27 refuse deux rangs identiques — l'app le voit avant la box
Livré AVANT le déploiement, parce que ce refus-là est muet : le motif ne franchit pas la
frontière RPC, et le symptôme au doigt aurait été « mon glissement ne s'enregistre pas »,
sans cause visible à l'écran. Même famille que le §9 du contrat de données.

Trois points vérifiés, dans l'ordre demandé :

1. LE RÉAPLATISSAGE REND TOUJOURS DES RANGS DISTINCTS. flattenToPayload numérote 1..n sur
   l'ordre courant, donc par construction — mais « par construction » ne se vérifie pas
   tout seul : le test le refait après CHAQUE permutation de domaines et après un
   déplacement intra-domaine, en partant de la config du banc qui porte déjà deux charges
   au rang 1. Les états intermédiaires du glissement ne partent pas : seul `save()` écrit.

2. RIEN NE PART SI LE BROUILLON N'EST PAS COHÉRENT. validateSet() — le miroir client de
   LoadConfigStore::validateSet() — apprend le contrôle des rangs et rend un conflit
   `tiedPriority` qui NOMME les deux charges et le rang partagé. L'écran distingue
   désormais les trois refus (thing revendiqué deux fois, identifiant en double, rangs à
   égalité) là où il annonçait « deux charges revendiquent le même appareil » pour tout.

3. ⚠️ LE CHEMIN DE SUPPRESSION ÉTAIT LE PLUS EXPOSÉ, et rien ne l'aurait montré. Il
   réécrit les survivantes VERBATIM — c'est sa règle, et elle est bonne — donc il
   transporte les rangs tels que la box les porte. Or c'est le plugin lui-même qui a créé
   des égalités avant +etm27 (borne détectée au rang 1 d'une installation déjà réglée).
   Une suppression légitime aurait donc été refusée en bloc, sans motif. Elle refuse
   maintenant côté app, avec la raison — plutôt que de renumérer en douce : un geste de
   nettoyage qui redistribue les rangs devient un geste d'arbitrage.

Deux tests de load_priority_test construisaient leurs entrées sans `priority` : les deux
tombaient donc à égalité, et le nouveau contrôle masquait la règle qu'ils éprouvaient.
Rangs distincts posés, avec la raison en commentaire.

Les deux autres nouveautés d'+etm27 sont au TODO, non codées : `o:rankOrigin` (badge « rang
par défaut, pas un choix », avec la règle absent = rien d'affirmé) et l'entrée en queue des
bornes auto-provisionnées — qui devrait faire taire d'elle-même la note d'ordre entrelacé.

213 tests (+8), flutter analyze inchangé à 27 remarques.

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

224 lines
11 KiB
Dart

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> _load(String fixture) {
final raw = File('test/fixtures/$fixture').readAsStringSync();
final map = jsonDecode(raw) as Map<String, dynamic>;
return (map['loadConfigs'] as List)
.cast<Map<String, dynamic>>()
.map(LoadConfigEntry.new)
.toList();
}
void main() {
// ───────────────────────────────────────────────────────────────────────────
group('§7-1 — aller-retour neutre', () {
test('le dump réel de .75 se réécrit à l\'identique, charges utiles vides comprises', () {
final entries = _load('loadconfig_hems75.json');
final payload = flattenToPayload(groupByDomain(entries).groups);
// Même nombre d'entrées, mêmes maps — clé par clé, y compris sgReady:{} et
// powerLevels:[] que la sérialisation par méta-objet du plugin produit.
expect(payload.length, entries.length);
for (var i = 0; i < payload.length; i++) {
expect(payload[i], equals(entries[i].raw),
reason: 'la charge ${entries[i].id} a été altérée par l\'aller-retour');
}
});
test('une clé inconnue de l\'app est transportée telle quelle', () {
// Simule une box plus récente qui aurait ajouté un champ que l'app ne modélise pas.
final entry = LoadConfigEntry({
'id': 'x', 'label': 'X', 'adapter': 'relay-router', 'mode': 'fixed',
'enabled': true, 'priority': 1,
'champInconnuDeLApp': {'imbriqué': [1, 2, 3]},
});
final patched = entry.patched(priority: 7);
expect(patched['champInconnuDeLApp'], equals({'imbriqué': [1, 2, 3]}));
expect(patched['priority'], 7);
});
});
// ───────────────────────────────────────────────────────────────────────────
group('§7-2 — aplatissement réversible', () {
test('config contiguë : lire → deux niveaux → réaplatir donne l\'ordre plat d\'origine', () {
final entries = _load('loadconfig_multidomain.json');
final grouped = groupByDomain(entries);
expect(grouped.isContiguous, isTrue);
final payload = flattenToPayload(grouped.groups);
final replayed = [
for (final m in payload) '${m['id']}:${m['priority']}',
];
final original = [
for (final e in grouped.flatOrder) '${e.id}:${e.priority}',
];
expect(replayed, equals(original));
});
test('les deux niveaux sont bien reconstitués dans l\'ordre d\'apparition', () {
final grouped = groupByDomain(_load('loadconfig_multidomain.json'));
expect(grouped.groups.map((g) => g.domain).toList(),
equals(['ev', 'ecs', 'hvac', kUnclassifiedDomain]));
expect(grouped.groups.first.loads.length, 2);
});
test('le rang de domaine vient de la liste à plat, PAS du défaut app', () {
// Ordre en place volontairement contraire à kDefaultDomainOrder : ecs avant ev.
final entries = [
LoadConfigEntry({'id': 'a', 'domain': 'ecs', 'priority': 1, 'adapter': 'relay-router'}),
LoadConfigEntry({'id': 'b', 'domain': 'ev', 'priority': 2, 'adapter': 'etmvariableload'}),
];
final grouped = groupByDomain(entries);
expect(grouped.isContiguous, isTrue);
// L'ordre RÉEL de la box est respecté, le défaut app ne s'impose pas à la lecture.
expect(grouped.groups.map((g) => g.domain).toList(), equals(['ecs', 'ev']));
expect(kDefaultDomainOrder.first, 'ev'); // le défaut existe mais ne s'applique pas ici
});
});
// ───────────────────────────────────────────────────────────────────────────
group('§7-3 — config non contiguë', () {
test('l\'entrelacement est détecté et nommé', () {
final grouped = groupByDomain(_load('loadconfig_interleaved.json'));
expect(grouped.isContiguous, isFalse);
expect(grouped.interleavedDomains, contains('ev'));
});
test('l\'ordre RÉEL de la box est exposé, pas une version regroupée', () {
final grouped = groupByDomain(_load('loadconfig_interleaved.json'));
expect(
grouped.flatOrder.map((e) => e.domain ?? kUnclassifiedDomain).toList(),
equals(['ev', 'ecs', 'ev', 'hvac', kUnclassifiedDomain]),
reason: 'flatOrder doit montrer l\'arbitrage tel qu\'il est, entrelacement compris',
);
});
test('la normalisation est un geste explicite, jamais un effet de la lecture', () {
final grouped = groupByDomain(_load('loadconfig_interleaved.json'));
// La lecture seule ne réordonne rien : les priorités d'origine sont intactes.
expect(grouped.flatOrder.map((e) => e.priority).toList(), equals([1, 2, 3, 4, 5]));
expect(grouped.flatOrder.map((e) => e.id).first, '{11111111-1111-4111-8111-111111111111}');
// Ce n'est qu'en appelant explicitement la normalisation que l'ordre change.
final normalized = flattenToPayload(applyDefaultDomainOrder(grouped.groups));
final evRanks = [
for (final m in normalized)
if (m['domain'] == 'ev') m['priority'] as int,
];
expect(evRanks, equals([1, 2]), reason: 'après normalisation, ev est contigu et en tête');
});
});
// ───────────────────────────────────────────────────────────────────────────
group('§4 — domaine : saisie, jamais déduction', () {
test('une charge sans domain tombe en « non classé » et n\'est jamais rangée d\'office', () {
final grouped = groupByDomain(_load('loadconfig_multidomain.json'));
final unclassified = grouped.groups.firstWhere((g) => g.isUnclassified);
expect(unclassified.loads.map((e) => e.id), contains('seche-serviettes'));
// Le sèche-serviettes est un relay-router, comme le chauffe-eau (ecs).
// Il ne doit PAS avoir été déduit comme ECS : un relais n'est pas forcément un ECS.
final ecs = grouped.groups.firstWhere((g) => g.domain == 'ecs');
expect(ecs.loads.map((e) => e.id), isNot(contains('seche-serviettes')));
});
test('un domain vide ou blanc est traité comme absent, pas comme un domaine nommé', () {
final e = LoadConfigEntry({'id': 'a', 'domain': ' ', 'priority': 1});
expect(e.domain, isNull);
});
test('un domaine inconnu de l\'app est conservé, pas écrasé', () {
final grouped = groupByDomain([
LoadConfigEntry({'id': 'a', 'domain': 'pool-heater', 'priority': 1}),
]);
expect(grouped.groups.single.domain, 'pool-heater');
// Et il part en queue à la normalisation, sans disparaître.
expect(applyDefaultDomainOrder(grouped.groups).single.domain, 'pool-heater');
});
});
// ───────────────────────────────────────────────────────────────────────────
group('claimedThingIds — miroir de loadconfig.cpp:150-166', () {
test('relay-router revendique ses relais', () {
final e = _load('loadconfig_hems75.json').single;
expect(e.claimedThingIds, {
'{8538782f-2c8c-4a30-bfce-c8140f791c9b}',
'{2ebe6bef-829b-4695-9d11-ddb4c16c5448}',
'{b033b212-1adb-4df0-ba2b-8fa477de52a2}',
});
});
test('etmvariableload revendique le Thing qui porte son propre id', () {
final e = LoadConfigEntry({
'id': '{11111111-1111-4111-8111-111111111111}',
'adapter': 'etmvariableload',
});
expect(e.claimedThingIds, {'{11111111-1111-4111-8111-111111111111}'});
});
test('sg-ready revendique les relais de tous ses états', () {
final e = _load('loadconfig_multidomain.json')
.firstWhere((c) => c.id == 'clim-salon');
expect(e.claimedThingIds, {
'{aaaa1111-0000-4000-8000-000000000001}',
'{aaaa1111-0000-4000-8000-000000000002}',
});
});
test('les formes avec et sans accolades sont normalisées', () {
final withBraces = LoadConfigEntry({
'id': 'x', 'adapter': 'relay-router',
'relays': [{'thingId': '{ABCD1234-0000-4000-8000-000000000001}', 'powerW': 1}],
});
final without = LoadConfigEntry({
'id': 'y', 'adapter': 'relay-router',
'relays': [{'thingId': 'abcd1234-0000-4000-8000-000000000001', 'powerW': 1}],
});
expect(withBraces.claimedThingIds, equals(without.claimedThingIds));
});
});
// ───────────────────────────────────────────────────────────────────────────
group('§4 — miroir de validateSet()', () {
test('identifiant en double refusé', () {
final c = validateSet([
LoadConfigEntry({'id': 'dup', 'priority': 1}),
LoadConfigEntry({'id': 'dup', 'priority': 2}),
]);
expect(c?.kind, 'duplicateId');
expect(c?.id, 'dup');
});
test('un Thing commandé par deux charges ACTIVES est refusé', () {
final relay = [{'thingId': '{8538782f-2c8c-4a30-bfce-c8140f791c9b}', 'powerW': 500}];
final c = validateSet([
// Rangs DISTINCTS : depuis +etm27 une égalité fait refuser l'ensemble, et le
// miroir la voit en premier — elle masquerait la règle éprouvée ici.
LoadConfigEntry({'id': 'a', 'label': 'A', 'priority': 1, 'adapter': 'relay-router', 'enabled': true, 'relays': relay}),
LoadConfigEntry({'id': 'b', 'label': 'B', 'priority': 2, 'adapter': 'relay-router', 'enabled': true, 'relays': relay}),
]);
expect(c?.kind, 'thingClaimedTwice');
expect(c?.firstLabel, 'A');
expect(c?.secondLabel, 'B');
});
test('une charge DÉSACTIVÉE ne revendique rien — pas de conflit', () {
final relay = [{'thingId': '{8538782f-2c8c-4a30-bfce-c8140f791c9b}', 'powerW': 500}];
final c = validateSet([
LoadConfigEntry({'id': 'a', 'label': 'A', 'priority': 1, 'adapter': 'relay-router', 'enabled': true, 'relays': relay}),
LoadConfigEntry({'id': 'b', 'label': 'B', 'priority': 2, 'adapter': 'relay-router', 'enabled': false, 'relays': relay}),
]);
expect(c, isNull);
});
test('la config réelle de .75 passe la validation', () {
expect(validateSet(_load('loadconfig_hems75.json')), isNull);
});
});
}