etm-powersync-app/test/load_hardware_test.dart
Patrick Schurig 2a8ad95120 feat(config): lot C — un seul écran de configuration, et le grisage vient du schéma
FUSION. L'écran « Ordre de service » est supprimé ; sa route redirige. C'est son CHEMIN DE
DONNÉES (LoadConfigProvider) qui a migré dans la présentation de « Rôles & appareils »,
jamais l'inverse. EmsRole fige six rôles dont dhw ET heatPump séparés — or LM-201 dit qu'une
PAC sans ballon séparé est UNE charge : le modèle des rôles aurait exigé deux charges sur un
canal unique, ce que validateSet() refuse à raison. Les charges viennent donc de
GetLoadConfig. Les compteurs restent sur EnergySetupProvider (autre contrat, SetRootMeter).

La note « le pilote peut bousculer l'ordre les jours Tempo rouge ou à l'approche d'une
échéance » est retirée : aucun de ces comportements n'existe dans le moteur.

ANNONCE AVANT ÉCRITURE. Miroir exact de EnergyArbitrator::sameHardware(). Le piège, vérifié
sur .75 : la comparaison des relais est INDEXÉE, donc réordonner relays[] sans rien changer
d'autre reconstruit l'adaptateur — contacts ouverts (applySafeState, force) et verrous
réarmés à froid. Un glisser-déposer dans la liste des contacteurs est un geste matériel.
L'écran le nomme, avec la durée de réarmement.

GRISAGE PAR INTROSPECTION. RpcSchema lit JSONRPC.Introspect à la connexion ; trois états et
non deux — écrivable, r: (mesure, pas fonction manquante), absent — plus « inconnu » quand le
schéma n'a pas pu être lu. Chaque champ grisé porte sa raison et le nom du champ manquant.
La version affichée est celle de l'API d'expérience (NymeaEnergy 0.8) : le paquet +etm15
n'est exposé par aucune méthode.

CORRIGÉ AU PASSAGE — les modes de recharge étaient INOPÉRANTS. setChargingInfo() appelait
EnergyPlugin.SetChargingInfo (namespace inexistant) avec mode/targetSoc/endTime/minCurrent
au lieu de chargingMode/targetPercentage/endDateTime, et minCurrent n'existe dans aucun
champ. Vérifié par appel réel : « Missing required key: chargingMode ». Le refus était de
plus avalé et l'état local mis à jour quand même. Désormais l'état ne suit que si la box
accepte, et le refus remonte sans en inventer la cause.

ZONES — constaté, pas corrigé. L'hypothèse « champs grisés » est fausse : AirConditioning
publie 8 méthodes toutes en écriture. ac_screen.dart n'en appelle aucune, ses 4 pièces sont
des constantes de maquette, et .75 déclare zéro zone. getZones() ajouté + bandeau qui lit le
vrai compte et annonce la maquette. Le câblage est un lot en soi, non exerçable sans zone.

Tests 80/80, analyze 0 erreur. Relevé : docs/RELEVE_LOTC.md — dont §6, ce qui n'a PAS pu
être vérifié : l'IU n'a pas pu être pilotée sur l'appareil, MIUI refusant INJECT_EVENTS.

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

221 lines
8.6 KiB
Dart

/// Ce que `SetLoadConfig` va **réellement** provoquer — miroir de
/// `EnergyArbitrator::sameHardware()`.
///
/// L'enjeu n'est pas cosmétique : une reconstruction ouvre les contacts et réarme les
/// verrous à froid. Sur une PAC à 900 s, l'installateur qui corrige une ligne fige sa
/// machine un quart d'heure. Ces tests fixent la frontière exacte entre ce qui se met à
/// jour en place et ce qui reconstruit.
///
/// Le cas le moins intuitif — et il est **vérifié sur `.75`** — est le simple
/// réordonnancement de `relays[]`.
library;
import 'package:etm_powersync_app/models/load_config_entry.dart';
import 'package:etm_powersync_app/services/load_hardware.dart';
import 'package:flutter_test/flutter_test.dart';
/// Charge `relay-router` à trois contacteurs, calquée sur `chauffe-eau` de `.75`.
Map<String, dynamic> chauffeEau({
List<Map<String, dynamic>>? relays,
int priority = 1,
String label = 'chauffe-eau',
bool enabled = true,
int minOnS = 60,
int minOffS = 60,
String domain = 'ecs',
}) =>
{
'adapter': 'relay-router',
'domain': domain,
'enabled': enabled,
'id': 'chauffe-eau',
'label': label,
'maxPowerW': 0,
'minOffS': minOffS,
'minOnS': minOnS,
'mode': 'fixed',
'needs': {'dailyDeadline': '', 'minEnergyWhPerDay': 0},
'powerLevels': <dynamic>[],
'priority': priority,
'relays': relays ??
[
{'powerW': 500, 'thingId': '{8538782f-2c8c-4a30-bfce-c8140f791c9b}'},
{'powerW': 1000, 'thingId': '{2ebe6bef-829b-4695-9d11-ddb4c16c5448}'},
{'powerW': 2000, 'thingId': '{b033b212-1adb-4df0-ba2b-8fa477de52a2}'},
],
'sgReady': <String, dynamic>{},
};
/// Charge `sg-ready` calquée sur `pac-terrain` de `.75`.
Map<String, dynamic> pacTerrain({
int minStateHoldS = 300,
double p4 = 3000,
int priority = 2,
bool enabled = true,
}) =>
{
'adapter': 'sg-ready',
'domain': 'hvac',
'enabled': enabled,
'id': 'pac-terrain',
'label': 'PAC banc',
'maxPowerW': 0,
'minOffS': 0,
'minOnS': 0,
'mode': 'fixed',
'needs': {'dailyDeadline': '', 'minEnergyWhPerDay': 0},
'powerLevels': <dynamic>[],
'priority': priority,
'relays': <dynamic>[],
'sgReady': {
'minStateHoldS': minStateHoldS,
'states': [
{'state': 1, 'relays': ['{beaf92e1-aedc-4b84-9ce4-e423648638cc}'], 'estimatedPowerW': 0.0},
{'state': 2, 'relays': <String>[], 'estimatedPowerW': 0.0},
{'state': 3, 'relays': ['{bf236e64-5ae7-4bf1-82a6-4ede03de75a6}'], 'estimatedPowerW': 1500.0},
{
'state': 4,
'relays': [
'{beaf92e1-aedc-4b84-9ce4-e423648638cc}',
'{bf236e64-5ae7-4bf1-82a6-4ede03de75a6}',
],
'estimatedPowerW': p4,
},
],
},
};
void main() {
group('sameHardware — ce qui NE reconstruit PAS', () {
test('le rang seul est une mise à jour en place', () {
final a = LoadConfigEntry(chauffeEau(priority: 1));
final b = LoadConfigEntry(chauffeEau(priority: 3));
expect(sameHardware(a, b), isTrue);
expect(hardwareDiff(a, b), isEmpty);
final impacts = impactOfSave(avant: [a], apres: [b]);
expect(impacts.single.impact, LoadChangeImpact.inPlace);
expect(impacts.single.needsWarning, isFalse);
});
test('le libellé et le domaine ne touchent pas au matériel', () {
final a = LoadConfigEntry(chauffeEau());
final b = LoadConfigEntry(chauffeEau(label: 'Ballon 200 L', domain: 'heating'));
expect(sameHardware(a, b), isTrue);
// Ni reconstruction NI mise à jour en place : le moteur réutilise l'adaptateur
// tel quel. Vérifié sur `.75` — un changement de libellé seul journalise
// « 2 inchangée(s) », et le journal continue d'afficher l'ANCIEN libellé
// jusqu'à la reconstruction suivante.
final impacts = impactOfSave(avant: [a], apres: [b]);
expect(impacts.single.impact, LoadChangeImpact.none);
});
});
group('sameHardware — ce qui RECONSTRUIT', () {
test('RÉORDONNER les relais suffit — la comparaison du moteur est indexée', () {
final origine = chauffeEau();
final inverse = chauffeEau(
relays: (origine['relays'] as List)
.cast<Map<String, dynamic>>()
.reversed
.toList(),
);
final a = LoadConfigEntry(origine);
final b = LoadConfigEntry(inverse);
// Même ensemble, mêmes Things, mêmes puissances — et pourtant :
expect(sameHardware(a, b), isFalse,
reason: 'vérifié sur .75 : relays[] inversé → adaptateur reconstruit, '
'ancien passé par applySafeState (contacts ouverts, force)');
expect(hardwareDiff(a, b), ['relays']);
final impacts = impactOfSave(avant: [a], apres: [b]);
expect(impacts.single.impact, LoadChangeImpact.rebuild);
expect(impacts.single.needsWarning, isTrue);
expect(impacts.single.rearmSeconds, 60); // max(minOnS, minOffS)
});
test('changer une puissance de contacteur reconstruit', () {
final a = LoadConfigEntry(chauffeEau());
final b = LoadConfigEntry(chauffeEau(relays: [
{'powerW': 800, 'thingId': '{8538782f-2c8c-4a30-bfce-c8140f791c9b}'},
{'powerW': 1000, 'thingId': '{2ebe6bef-829b-4695-9d11-ddb4c16c5448}'},
{'powerW': 2000, 'thingId': '{b033b212-1adb-4df0-ba2b-8fa477de52a2}'},
]));
expect(hardwareDiff(a, b), ['relays']);
});
test('les temporisations sont du matériel au sens du moteur', () {
final a = LoadConfigEntry(chauffeEau(minOnS: 60));
final b = LoadConfigEntry(chauffeEau(minOnS: 120));
expect(hardwareDiff(a, b), ['minOnS']);
expect(impactOfSave(avant: [a], apres: [b]).single.rearmSeconds, 120);
});
test('une estimation SG-Ready revue reconstruit, et réarme minStateHoldS', () {
final a = LoadConfigEntry(pacTerrain(p4: 3000));
final b = LoadConfigEntry(pacTerrain(p4: 2800));
expect(hardwareDiff(a, b), ['sgReady']);
final impact = impactOfSave(avant: [a], apres: [b]).single;
expect(impact.impact, LoadChangeImpact.rebuild);
// C'est CE nombre qu'il faut annoncer : sur une vraie PAC il vaut 900.
expect(impact.rearmSeconds, 300);
});
test('changer de mécanisme reconstruit', () {
final a = LoadConfigEntry(chauffeEau());
final b = LoadConfigEntry({...chauffeEau(), 'adapter': 'etmvariableload'});
expect(hardwareDiff(a, b), contains('adapter'));
});
});
group('entrée et sortie de l\'arbitrage', () {
test('enabled vrai → faux : la charge SORT, elle passe par l\'état sûr', () {
final a = LoadConfigEntry(pacTerrain(enabled: true));
final b = LoadConfigEntry(pacTerrain(enabled: false));
final impact = impactOfSave(avant: [a], apres: [b]).single;
expect(impact.impact, LoadChangeImpact.removed);
expect(impact.needsWarning, isTrue);
});
test('enabled faux → vrai : la charge ENTRE', () {
final a = LoadConfigEntry(pacTerrain(enabled: false));
final b = LoadConfigEntry(pacTerrain(enabled: true));
expect(impactOfSave(avant: [a], apres: [b]).single.impact,
LoadChangeImpact.created);
});
test('désactivée avant comme après : aucun adaptateur, donc rien à reconstruire', () {
final a = LoadConfigEntry(pacTerrain(enabled: false, minStateHoldS: 300));
final b = LoadConfigEntry(pacTerrain(enabled: false, minStateHoldS: 900));
expect(impactOfSave(avant: [a], apres: [b]).single.impact,
LoadChangeImpact.none);
});
test('une charge retirée de l\'ensemble sort par l\'état sûr', () {
final a = LoadConfigEntry(chauffeEau());
final b = LoadConfigEntry(pacTerrain());
final impacts = impactOfSave(avant: [a, b], apres: [b]);
final ce = impacts.firstWhere((i) => i.loadId == 'chauffe-eau');
expect(ce.impact, LoadChangeImpact.removed);
});
});
test('un enregistrement mixte rend un verdict par charge', () {
final avant = [LoadConfigEntry(chauffeEau()), LoadConfigEntry(pacTerrain())];
final apres = [
LoadConfigEntry(chauffeEau(priority: 5)), // en place
LoadConfigEntry(pacTerrain(minStateHoldS: 900)), // reconstruction
];
final impacts = {
for (final i in impactOfSave(avant: avant, apres: apres)) i.loadId: i
};
expect(impacts['chauffe-eau']!.impact, LoadChangeImpact.inPlace);
expect(impacts['pac-terrain']!.impact, LoadChangeImpact.rebuild);
expect(impacts['pac-terrain']!.rearmSeconds, 900);
expect(impacts.values.where((i) => i.needsWarning).length, 1);
});
}