1) GLISSEMENT — diagnostic d'abord, et il a donné DEUX réponses. Le geste n'est PAS avalé par le ListView parent : les deux montages du test (section seule / section imbriquée) glissent identiquement. La cause du symptôme était bien que .75 n'a qu'une charge par domaine — rien à réordonner. Mais l'enquête a trouvé un vrai défaut au passage : le centre de la poignée tombait dans le vide de 3 px séparant l'icône de la pastille de rang, et ReorderableDragStartListener défère le test de collision à ses enfants. Une bande morte invisible au milieu d'une poignée qui paraît d'un seul tenant. Corrigé par un Container transparent. Tentative faite et ANNULÉE : rendre les domaines glissables aussi. Deux ReorderableListView imbriqués se disputent le geste et l'extérieur gagne — le glissement des cartes cassait. Les domaines gardent leurs flèches, et une charge seule dans son domaine n'offre plus de prise qui mènerait nulle part (avec l'infobulle qui renvoie au bon geste). Vérifié SUR L'APPAREIL, contre .75, avec les deux charges mises dans le même domaine : [chauffe-eau, pac-terrain] → [pac-terrain, chauffe-eau], brouillon abandonné sans rien écrire. Classement ecs/hvac rétabli ensuite. 2) SÉLECTEUR DE MÉCANISME — actif, et c'était le morceau délicat. LoadConfig est une union discriminée : écrire `adapter` sans nettoyer le reste produit une configuration que la box refuse EN BLOC, et le refus emporte toutes les charges de l'appel. D'où basculerMecanisme(), qui retire les clés du mécanisme quitté et pose celles du nouveau, en récupérant ce qui peut l'être — les deux premiers contacts deviennent SG1/SG2 dans l'encodage standard, les relais gardent leur puissance. Ce qui n'est PAS transposé : l'estimation d'un état SG-Ready n'est pas la puissance d'une résistance. La charge produite est alors incomplète, et c'est voulu — le formulaire réclame plutôt que d'inventer une valeur de plaque. La bascule franchit sameHardware() : confirmation préalable qui annonce la reconstruction et la durée de réarmement (900 s par défaut vers SG-Ready — la valeur d'une PAC réelle, pas celle du banc). Le mode fixed d'etmvariableload reste LISIBLE sans être proposé : bloc « Configuration héritée » en lecture seule, et conversion seulement sur geste explicite, avec le plafond que la déclaration en paliers ne porte pas. Ouvrir l'écran ne convertit personne. 3) CHIP DE DOMAINE — il ne rendait qu'une icône muette : le domaine d'une charge était illisible sur sa carte, alors qu'il décide de son groupe et de son rang. Il porte maintenant son libellé, et sa couleur suit le thème. 4) THÈME — les 11 derniers sites (pas 9) basculés. Trois cas : contexte disponible (main, ac_screen) ; peintre sans contexte, à qui la couleur est passée résolue (energy_flow_widget) ; tables statiques, où la teinte devient nullable et se résout au rendu (categoryInfoMap, favoris). Plus AUCUN jeton hors thème dans lib/. 5) ZONES — non entreprises, comme convenu. Tests 93/93, analyze 0 erreur. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff
199 lines
7.4 KiB
Dart
199 lines
7.4 KiB
Dart
/// 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': <dynamic>[],
|
|
'priority': 1,
|
|
'relays': [
|
|
{'powerW': 500, 'thingId': _k1},
|
|
{'powerW': 1000, 'thingId': _k2},
|
|
{'powerW': 2000, 'thingId': _r3},
|
|
],
|
|
'sgReady': <String, dynamic>{},
|
|
});
|
|
|
|
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': <dynamic>[],
|
|
'priority': 2,
|
|
'relays': <dynamic>[],
|
|
'sgReady': {
|
|
'minStateHoldS': 300,
|
|
'states': [
|
|
{'state': 1, 'relays': [_k1], 'estimatedPowerW': 0.0},
|
|
{'state': 2, 'relays': <String>[], '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': <dynamic>[],
|
|
'sgReady': <String, dynamic>{},
|
|
});
|
|
|
|
/// Ce que la box exige : les charges utiles étrangères doivent être VIDES.
|
|
void _chargesUtilesEtrangeresVides(Map<String, dynamic> 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);
|
|
});
|
|
}
|