/// 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 chauffeEau({ List>? 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': [], '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': {}, }; /// Charge `sg-ready` calquée sur `pac-terrain` de `.75`. Map 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': [], 'priority': priority, 'relays': [], 'sgReady': { 'minStateHoldS': minStateHoldS, 'states': [ {'state': 1, 'relays': ['{beaf92e1-aedc-4b84-9ce4-e423648638cc}'], 'estimatedPowerW': 0.0}, {'state': 2, 'relays': [], '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>() .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); }); }