etm-powersync-app/test/load_priority_test.dart
Patrick Schurig 7ca42973f2 feat(installateur): ordre de service des charges + locale + ossature i18n
Lot B côté app, partie NON bloquée par le lot plugin (voir §Limites).

§1 Locale (indépendant) — JSONRPC.Hello portait {} : l'app était en en_US de
fait alors que nymea gère la locale PAR CONNEXION (o:locale). Envoi de la
locale à chaque Hello, donc aussi après reconnexion subie (m_clientLocales
repart au défaut de la box à chaque socket). Locale demandée ET retenue
journalisées : un .qm introuvable rend exactement comme une absence de
traduction, sans cette trace un catalogue mal packagé passe inaperçu.

§2 i18n — flutter_localizations + intl + ARB (français seul). Aucune chaîne
en dur dans le nouvel écran. Les codes reçus de la box (domaine, mécanisme)
sont des clés ARB avec repli explicite sur code brut si l'app ne les connaît
pas : une box en avance sur l'app est un cas normal en parc déployé.

§3 Modèle — LoadConfigEntry est une VUE en lecture au-dessus de la map brute ;
l'écriture repart de la map d'origine (patched()). Pas de couche de filtrage
préventive : l'aller-retour neutre passe contre .75, donc l'écho verbatim est
sûr par construction (§7-1).

§4 Priorité à deux niveaux — groupByDomain / flattenToPayload. Le rang de
domaine se déduit de l'ordre d'apparition à plat ; la box ne sait rien des
groupes. Config non contiguë : signalée, ordre réel affiché, normalisation
seulement sur confirmation explicite. Miroir client de validateSet().

§5 Écritures asynchrones — LoadConfigProvider ne conclut jamais sur
l'acquittement du RPC : pending → LoadConfigChanged → confirmed|diverged.

NymeaEnergy.GetLoadConfig / SetLoadConfig deviennent RÉELS (ils étaient des
stubs journalisés). Conséquence traitée : EnergySetupProvider.persist()
n'émet plus setLoadConfig(buildLoadDescriptors()) — SetLoadConfig remplace
TOUT l'ensemble, et _commit() l'appelait à chaque changement d'UI : une
reconstruction depuis le modèle typé aurait écrasé la config réelle de la
box sans geste explicite.

Écran /energy/loads, gaté installateur (drawer + garde de routeur).

Limites — le lot plugin n'est PAS déployé sur .75 : GetLoadTelemetry,
LoadTelemetryChanged et o:domain sont absents (19 méthodes / 13 notifications
inchangées). Toute la moitié télémétrie du brief est donc hors d'atteinte, et
o:domain n'étant pas au schéma, la box rejetterait la clé (jsonvalidator.cpp:143).
Fixtures multi-domaines synthétiques : .75 n'a qu'une charge.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 11:34:53 +02:00

222 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([
LoadConfigEntry({'id': 'a', 'label': 'A', 'adapter': 'relay-router', 'enabled': true, 'relays': relay}),
LoadConfigEntry({'id': 'b', 'label': 'B', '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', 'adapter': 'relay-router', 'enabled': true, 'relays': relay}),
LoadConfigEntry({'id': 'b', 'label': 'B', '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);
});
});
}