/// La bascule de mécanisme — **ce qui part doit partir**. /// /// `LoadConfig` est une union discriminée : la charge utile d'un mécanisme quitté doit /// être absente, sinon la box refuse **en bloc et sans motif**, et le refus emporte toutes /// les charges de l'appel. Ces tests reprennent les règles de `LoadConfig::isValid()` du /// plugin, mécanisme par mécanisme. library; import 'package:etm_powersync_app/models/load_config_entry.dart'; import 'package:etm_powersync_app/services/load_hardware.dart'; import 'package:etm_powersync_app/services/load_mechanism_switch.dart'; import 'package:flutter_test/flutter_test.dart'; const _k1 = '{beaf92e1-aedc-4b84-9ce4-e423648638cc}'; const _k2 = '{bf236e64-5ae7-4bf1-82a6-4ede03de75a6}'; const _r3 = '{b033b212-1adb-4df0-ba2b-8fa477de52a2}'; LoadConfigEntry _routeur() => LoadConfigEntry({ 'adapter': 'relay-router', 'domain': 'ecs', 'enabled': true, 'id': 'chauffe-eau', 'label': 'chauffe-eau', 'maxPowerW': 0, 'minOffS': 60, 'minOnS': 60, 'mode': 'fixed', 'needs': {'dailyDeadline': '06:00', 'minEnergyWhPerDay': 4000}, 'powerLevels': [], 'priority': 1, 'relays': [ {'powerW': 500, 'thingId': _k1}, {'powerW': 1000, 'thingId': _k2}, {'powerW': 2000, 'thingId': _r3}, ], 'sgReady': {}, }); LoadConfigEntry _pac() => LoadConfigEntry({ 'adapter': 'sg-ready', 'domain': 'hvac', 'enabled': true, 'id': 'pac-terrain', 'label': 'PAC banc', 'maxPowerW': 0, 'minOffS': 0, 'minOnS': 0, 'mode': 'fixed', 'needs': {'dailyDeadline': '', 'minEnergyWhPerDay': 0}, 'powerLevels': [], 'priority': 2, 'relays': [], 'sgReady': { 'minStateHoldS': 300, 'states': [ {'state': 1, 'relays': [_k1], 'estimatedPowerW': 0.0}, {'state': 2, 'relays': [], 'estimatedPowerW': 0.0}, {'state': 3, 'relays': [_k2], 'estimatedPowerW': 1500.0}, {'state': 4, 'relays': [_k1, _k2], 'estimatedPowerW': 3000.0}, ], }, }); /// Legacy : `etmvariableload` en multipalier natif. LoadConfigEntry _heritage() => LoadConfigEntry({ 'adapter': 'etmvariableload', 'domain': 'ecs', 'enabled': true, 'id': '{aaaaaaaa-0000-0000-0000-000000000001}', 'label': 'ECS Modbus', 'maxPowerW': 0, 'minOffS': 0, 'minOnS': 0, 'mode': 'fixed', 'needs': {'dailyDeadline': '', 'minEnergyWhPerDay': 0}, 'powerLevels': [0, 600, 1200, 1800, 2400], 'priority': 3, 'relays': [], 'sgReady': {}, }); /// Ce que la box exige : les charges utiles étrangères doivent être VIDES. void _chargesUtilesEtrangeresVides(Map m, String mecanisme) { if (mecanisme != 'relay-router') { expect((m['relays'] as List?) ?? const [], isEmpty, reason: '$mecanisme : relays[] interdit'); } if (mecanisme != 'sg-ready') { expect((m['sgReady'] as Map?) ?? const {}, isEmpty, reason: '$mecanisme : sgReady interdit'); } if (mecanisme == 'sg-ready') { expect((m['powerLevels'] as List?) ?? const [], isEmpty, reason: 'sg-ready : powerLevels interdit'); expect((m['maxPowerW'] as num?) ?? 0, 0, reason: 'sg-ready : maxPowerW interdit'); } } void main() { group('routeur → SG-Ready', () { test('les relais partent, les deux premiers deviennent SG1 et SG2', () { final m = basculerMecanisme(_routeur(), 'sg-ready'); _chargesUtilesEtrangeresVides(m, 'sg-ready'); final e = LoadConfigEntry(m); expect(e.adapter, 'sg-ready'); expect(e.mode, 'fixed'); // la box refuse toute autre valeur sur ce mécanisme final parEtat = {for (final s in e.sgReadyStates) s.state: s.relays}; expect(parEtat[1], [_k1]); expect(parEtat[2], isEmpty, reason: 'l\'état 2 doit rester exprimable'); expect(parEtat[3], [_k2]); expect(parEtat[4], [_k1, _k2]); }); test('le maintien d\'état par défaut est celui d\'une PAC RÉELLE, pas du banc', () { final e = LoadConfigEntry(basculerMecanisme(_routeur(), 'sg-ready')); expect(e.minStateHoldS, 900, reason: 'mieux vaut une protection trop longue qu\'un court-cycle'); }); test('l\'identité et le classement survivent', () { final e = LoadConfigEntry(basculerMecanisme(_routeur(), 'sg-ready')); expect(e.id, 'chauffe-eau'); expect(e.label, 'chauffe-eau'); expect(e.domain, 'ecs'); expect(e.priority, 1); expect(e.dailyDeadline, '06:00', reason: 'needs n\'appartient à aucun mécanisme'); }); }); group('PAC → routeur', () { test('les contacts redeviennent des relais, puissance À SAISIR', () { final m = basculerMecanisme(_pac(), 'relay-router'); _chargesUtilesEtrangeresVides(m, 'relay-router'); final e = LoadConfigEntry(m); expect(e.adapter, 'relay-router'); expect(e.relays.map((r) => r.thingId), [_k1, _k2]); // Une estimation d'état n'est PAS la puissance d'une résistance : on ne la // transpose pas, on laisse le formulaire réclamer. expect(e.relays.every((r) => r.powerW == 0), isTrue); }); test('la configuration produite est INCOMPLÈTE, et c\'est voulu', () { final e = LoadConfigEntry(basculerMecanisme(_pac(), 'relay-router')); // powerW ≤ 0 : la box refuserait. C'est au formulaire de le dire avant l'envoi. expect(e.relays.any((r) => r.powerW <= 0), isTrue); }); }); group('→ modulable', () { test('aucun contact ne survit, et le mode est TOUJOURS dynamic', () { for (final source in [_routeur(), _pac()]) { final m = basculerMecanisme(source, 'etmvariableload'); _chargesUtilesEtrangeresVides(m, 'etmvariableload'); final e = LoadConfigEntry(m); expect(e.mode, 'dynamic', reason: 'le multipalier natif est legacy : jamais produit'); expect((m['powerLevels'] as List?) ?? const [], isEmpty, reason: 'la box refuse powerLevels avec dynamic'); } }); }); group('le mode fixed hérité se LIT sans être réécrit', () { test('il est reconnu', () { expect(estHeritageFixed(_heritage()), isTrue); expect(estHeritageFixed(_routeur()), isFalse); expect(estHeritageFixed(_pac()), isFalse); }); test('ouvrir l\'écran ne convertit pas — seul un geste explicite le fait', () { final avant = _heritage(); // Rien n'est appelé : lire ne transforme pas. La conversion est un appel SÉPARÉ. expect(avant.mode, 'fixed'); expect(avant.powerLevelsInt, [0, 600, 1200, 1800, 2400]); final apres = LoadConfigEntry(convertirEnModulation(avant, 2400)); expect(apres.mode, 'dynamic'); expect(apres.maxPowerW, 2400); expect(apres.powerLevelsInt, isEmpty, reason: 'la box refuse powerLevels avec dynamic'); }); }); test('changer de mécanisme est une RECONSTRUCTION, jamais une mise à jour', () { final avant = _routeur(); final apres = LoadConfigEntry(basculerMecanisme(avant, 'sg-ready')); expect(sameHardware(avant, apres), isFalse); final impact = impactOfSave(avant: [avant], apres: [apres]).single; expect(impact.impact, LoadChangeImpact.rebuild); expect(impact.changedFields, contains('adapter')); // C'est CE nombre que l'écran doit annoncer avant de valider. expect(impact.rearmSeconds, 900); }); }