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
256 lines
10 KiB
Dart
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);
|
|
});
|
|
}
|