etm-powersync-app/test/load_domain_test.dart
Patrick Schurig dee20896c5 feat(télémétrie): lot B app — consommation de la télémétrie d'arbitrage
Le lot plugin B-bis (1.15.2+etm15) est sur .75 et GetLoadConfig/GetLoadTelemetry
concordent : les critères 4 à 8 du brief deviennent atteignables.

- models/load_telemetry.dart : vue en lecture. timestamp/budget/mechanism/lock/
  faultCode nullables — omis porte un sens que zéro ne porterait pas.
- services/telemetry_text.dart : rendu français des CODES (19 motifs, 3 défauts,
  3 verrous, 4 mécanismes). Repli lisible sur un code inconnu ET sur un code connu
  privé d'un paramètre : une phrase trouée a l'air d'une donnée, le code brut a
  l'air de ce qu'il est.
- providers/load_telemetry_provider.dart : instantané + LoadTelemetryChanged,
  fraîcheur à trois signaux (âge du timestamp, silence des trames, timestamp qui
  cesse d'avancer), machine à états de ClearLoadFault qui ne conclut JAMAIS sur
  l'acquittement du RPC.
- load_config_provider : assignDomain() en brouillon, énumération fermée.
- load_order_screen : budget, fraîcheur, mode dégradé, bloc par charge, encart
  défaut, menu de domaine.
- nymea_service : getLoadTelemetry(), clearLoadFault(), aiguillage de la
  notification. Une box sans la méthode s'annonce comme telle, pas comme en panne.

Seuil de fraîcheur à 180 s : le battement de cœur du plugin est de 60 s et le
timestamp avance d'un cycle par minute. Un seuil plus court crierait « données
anciennes » sur une installation vivante.

Fixtures v3 prises sur .75 après déploiement, dont un cycle porteur d'un verrou —
« lock » étant optionnel, un dump pris au mauvais moment ne l'éprouverait pas.

57 tests (30 avant), flutter analyze inchangé à 25 issues.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPUo3RMr8SzK6qbFtfBm8H
2026-08-25 19:20:30 +02:00

100 lines
4.4 KiB
Dart

import 'dart:convert';
import 'dart:io';
import 'package:etm_powersync_app/providers/load_config_provider.dart';
import 'package:etm_powersync_app/services/load_priority.dart';
import 'package:flutter_test/flutter_test.dart';
List<Map<String, dynamic>> _configs(String fixture) {
final raw = File('test/fixtures/$fixture').readAsStringSync();
final map = jsonDecode(raw) as Map<String, dynamic>;
return (map['loadConfigs'] as List).cast<Map<String, dynamic>>();
}
void main() {
// ───────────────────────────────────────────────────────────────────────────
group('§7-4 — domaine', () {
test('les deux charges de .75 arrivent « non classées », jamais rangées d\'office',
() {
final p = LoadConfigProvider()..adoptForTest(_configs('loadconfig_hems75_v3.json'));
// `domain: ""` sort toujours de GetLoadConfig (sérialisation par méta-objet) ;
// vide ET absent se lisent tous deux « non classée ».
expect(p.groups.length, 1);
expect(p.groups.single.isUnclassified, isTrue);
expect(p.groups.single.loads.map((e) => e.id),
['chauffe-eau', 'pac-terrain']);
expect(p.hasPendingChanges, isFalse,
reason: 'une simple lecture ne doit jamais produire de brouillon');
});
test('classer une charge la déplace dans son domaine — dans le brouillon seulement',
() {
final p = LoadConfigProvider()..adoptForTest(_configs('loadconfig_hems75_v3.json'));
p.assignDomain('chauffe-eau', 'ecs');
expect(p.hasPendingChanges, isTrue);
expect(p.groups.map((g) => g.domain), [kUnclassifiedDomain, 'ecs']);
expect(p.groups.first.loads.single.id, 'pac-terrain');
expect(p.groups.last.loads.single.domain, 'ecs');
});
test('la charge utile du mécanisme traverse l\'affectation intacte', () {
final source = _configs('loadconfig_hems75_v3.json');
final p = LoadConfigProvider()..adoptForTest(source);
p.assignDomain('pac-terrain', 'hvac');
final payload = flattenToPayload(p.groups);
final pac = payload.firstWhere((m) => m['id'] == 'pac-terrain');
final origin = source.firstWhere((m) => m['id'] == 'pac-terrain');
// Seuls `domain` et `priority` changent : la table d'états SG-Ready, les relais
// vides, `needs` et `powerLevels` sont transportés tels quels. Une
// re-sérialisation depuis un modèle typé les amputerait en silence.
expect(pac['domain'], 'hvac');
for (final key in origin.keys) {
if (key == 'domain' || key == 'priority') continue;
expect(pac[key], equals(origin[key]),
reason: 'la clé $key a été altérée par l\'affectation de domaine');
}
});
test('déclasser une charge la ramène au bucket « non classé »', () {
final p = LoadConfigProvider()..adoptForTest(_configs('loadconfig_hems75_v3.json'));
p.assignDomain('chauffe-eau', 'ecs');
p.assignDomain('chauffe-eau', kUnclassifiedDomain);
final ecs = p.groups.where((g) => g.domain == 'ecs');
expect(ecs, isEmpty, reason: 'un groupe vidé ne doit pas subsister');
expect(
p.groups
.firstWhere((g) => g.isUnclassified)
.loads
.map((e) => e.id)
.toSet(),
{'chauffe-eau', 'pac-terrain'});
});
test('l\'aplatissement renumérote 1..N sans jamais réordonner les groupes', () {
final p = LoadConfigProvider()..adoptForTest(_configs('loadconfig_hems75_v3.json'));
p.assignDomain('chauffe-eau', 'ecs');
final payload = flattenToPayload(p.groups);
expect(payload.map((m) => m['id']), ['pac-terrain', 'chauffe-eau']);
expect(payload.map((m) => m['priority']), [1, 2]);
});
test('les domaines proposés sont exactement l\'énumération fermée du plugin', () {
// `SetLoadConfig` rejette EN BLOC une valeur hors énumération : proposer un
// domaine inventé ferait échouer l'enregistrement de toutes les charges.
expect(kAssignableDomains.toSet(), {'ev', 'ecs', 'hvac', 'heating', 'battery'});
});
test('l\'ordre de mise en service ne contient pas la batterie', () {
// Classable, mais sa place ne s'exprime pas en watts : elle s'exprime en cible
// de SOC, hors périmètre de l'ordre de service.
expect(kDefaultDomainOrder, isNot(contains('battery')));
});
});
}