etm-powersync-app/test/load_hardware_test.dart
Patrick Schurig cd76df3a73 feat(etm16): s'aligner sur le nouveau contrat — table de paliers, stagesW, compteur et sonde
RELAIS : mon avertissement de reconstruction sur un réordonnancement est devenu FAUX, et
il est corrigé. `+etm16` compare la TABLE que les relais produisent (deriveStages), plus la
liste par index. Le miroir Dart suit, y compris le détail qui compte : les ThingIds d'une
combinaison se comparent comme un ENSEMBLE — collectés dans l'ordre de déclaration, fermer
{K1,K2} ou {K2,K1} est le même geste électrique. Mais l'appariement palier par palier
reste positionnel, ce qui préserve le seul cas où l'ordre signifie encore quelque chose :
deux relais de MÊME puissance, où il décide quel contact sert ce palier.

Conséquence pour l'écran : réordonner des contacteurs de puissances distinctes n'avertit
plus de rien. Avertir sur un geste devenu gratuit serait aussi trompeur que de se taire sur
un geste coûteux.

PALIERS : l'app ne dérive plus la combinatoire pour l'affichage — la box les publie
(mechanism.stagesW). Vérifié sur appareil : [0, 500, 1000, 1500, 2000, 2500, 3000, 3500].
deriveStages reste côté app pour la seule annonce d'impact, qui se fait avant l'écriture.
Tant que le brouillon n'a pas touché aux contacteurs, c'est la table PUBLIÉE qui s'affiche ;
dès qu'il y touche elle devient périmée, et l'écran montre une prévision annoncée comme
telle plutôt qu'une table qui décrit l'état d'avant.

COMPTEUR ET SONDE : ils ne « vont pas arriver », ils SONT au schéma de .75
(o:meterThingId, o:sensorThingId). Le grisage dérivé les a donc activés seul, sans nouvelle
version de l'app — exactement ce qui était promis. Mais un bloc actif sur un placeholder
mort est pire qu'un bloc grisé : les deux sont désormais câblés sur un vrai sélecteur,
filtré par INTERFACE (smartmeter, temperaturesensor) et jamais par nom de plugin.

Ni l'un ni l'autre n'entre dans sameHardware() : les rattacher ne reconstruit rien. Ni dans
le contrôle d'unicité : deux charges peuvent partager une sonde, un compteur divisionnaire
en couvrir plusieurs — le sélecteur ne grise donc rien ici, contrairement aux contacteurs.
Et la mesure sert à VÉRIFIER, jamais à décider : elle n'entre pas dans le budget, la charge
étant déjà comptée dans le racine dont elle n'est qu'une décomposition.

COMPTEURS CUMULÉS : la base purgée, les valeurs sont saines (55 730 / 50 / 6 327 / 62 007
kWh) et la tuile « compteur box invalide » a disparu d'elle-même. Le plafond de
plausibilité reste : un zéro réel s'affiche « 0,0 kWh », jamais « — ».

hasNeverRun était déjà traité comme un état distinct — « l'absence de cycle est dite pour
elle-même, jamais rendue par un âge de zéro ».

Vérifié sur appareil contre .75 : compteur racine relu, « à configurer » vide, deux bornes
listées (Simulated wallbox + Terra AC Charger), stagesW de la box, les deux champs
writable, 5/5 sections de l'écran SG-Ready. Tests 98/98, analyze 0 erreur.

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

256 lines
10 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 — les relais, comparés par leur TABLE', () {
test('réordonner des relais de puissances DISTINCTES ne reconstruit RIEN', () {
// Comportement inversé par le moteur en `+etm16`, après signalement : la comparaison
// porte sur la TABLE de paliers, pas sur la liste. Même ensemble, mêmes puissances,
// ordre différent → même table → aucune reconstruction. Vérifié au banc : le journal
// dit « 2 INCHANGÉE(S) », aucun état sûr, aucune commutation.
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);
expect(sameHardware(a, b), isTrue,
reason: 'la table produite est identique — rien à reconstruire');
expect(hardwareDiff(a, b), isEmpty);
expect(impactOfSave(avant: [a], apres: [b]).single.needsWarning, isFalse);
});
test('échanger deux relais de MÊME puissance reconstruit — le contact change', () {
// Le cas où l'ordre reste significatif, et le seul : à somme égale la table retient
// la PREMIÈRE combinaison rencontrée. Échanger deux relais identiques change donc le
// contact qui sert ce palier — autre contacteur qui s'use, autre résistance qui
// chauffe. Un ensemble ne le verrait pas.
const rA = '{aaaaaaaa-0000-0000-0000-00000000000a}';
const rB = '{bbbbbbbb-0000-0000-0000-00000000000b}';
final a = LoadConfigEntry(chauffeEau(relays: [
{'powerW': 1000, 'thingId': rA},
{'powerW': 1000, 'thingId': rB},
]));
final b = LoadConfigEntry(chauffeEau(relays: [
{'powerW': 1000, 'thingId': rB},
{'powerW': 1000, 'thingId': rA},
]));
expect(hardwareDiff(a, b), ['relays']);
expect(impactOfSave(avant: [a], apres: [b]).single.impact,
LoadChangeImpact.rebuild);
});
test('la table dédoublonne : deux relais identiques ne font pas 4 paliers', () {
final t = deriveStages(const [
LoadRelay(thingId: '{a}', powerW: 1000),
LoadRelay(thingId: '{b}', powerW: 1000),
]);
expect(t.levels, [0, 1000, 2000]);
// Le palier 1000 est servi par le PREMIER relais déclaré.
expect(t.mapping[1], ['{a}']);
});
test('la table du banc : trois relais → huit paliers', () {
final t = deriveStages(LoadConfigEntry(chauffeEau()).relays);
// Exactement ce que la box publie en télémétrie sur `.75`.
expect(t.levels, [0, 500, 1000, 1500, 2000, 2500, 3000, 3500]);
});
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);
});
}