etm-powersync-app/test/load_telemetry_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

278 lines
12 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:convert';
import 'dart:io';
import 'package:etm_powersync_app/l10n/app_localizations.dart';
import 'package:etm_powersync_app/models/load_telemetry.dart';
import 'package:etm_powersync_app/services/telemetry_text.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
Map<String, dynamic> _fixture(String name, String key) {
final raw = File('test/fixtures/$name').readAsStringSync();
return Map<String, dynamic>.from(
(jsonDecode(raw) as Map<String, dynamic>)[key] as Map);
}
void main() {
late L10n t;
setUpAll(() async {
t = await L10n.delegate.load(const Locale('fr'));
});
// ───────────────────────────────────────────────────────────────────────────
group('§7-5 — lecture de la charge utile réelle de .75', () {
test('le dump du banc se lit entièrement, budget et mécanismes compris', () {
final tel =
LoadTelemetry.fromJson(_fixture('loadtelemetry_hems75_v3.json', 'telemetry'));
expect(tel.loads.map((l) => l.loadId), ['chauffe-eau', 'pac-terrain']);
expect(tel.degradedMode, isFalse);
expect(tel.hasNeverRun, isFalse);
expect(tel.timestamp, isNotNull);
final ecs = tel.entryFor('chauffe-eau')!;
expect(ecs.available, isTrue);
expect(ecs.faultCode, isNull);
expect(ecs.mechanism!.kind, 'relay');
expect(ecs.mechanism!.stageW, 3500);
expect(ecs.decision.code, 'SURPLUS_SETPOINT');
final pac = tel.entryFor('pac-terrain')!;
expect(pac.mechanism!.kind, 'sgReady');
expect(pac.mechanism!.state, 4);
expect(pac.decision.code, 'SG_FORCED');
// Aucun verrou sur ce cycle-là : `lock` est optionnel, et son absence se lit
// « aucun verrou ne mord », jamais « verrou de zéro seconde ».
expect(pac.lock, isNull);
});
test('un cycle antérieur du même banc porte un verrou minStateHold', () {
// Même installation, trois cycles plus tôt. Sans ce second dump, la lecture du
// verrou ne serait éprouvée que sur une charge utile fabriquée.
final tel = LoadTelemetry.fromJson(
_fixture('loadtelemetry_hems75_v3_locked.json', 'telemetry'));
final pac = tel.entryFor('pac-terrain')!;
expect(pac.lock!.kind, 'minStateHold');
expect(pac.lock!.remainingS, 60);
expect(tel.budget!.identityHolds, isTrue);
});
test('l\'identité du budget annoncée par le plugin se vérifie sur le dump réel', () {
// surplusW + recreditedW − allocatedW == remainingW. Sans le terme de recrédit,
// l'égalité serait fausse dès qu'une charge consomme déjà — le cas normal.
final tel =
LoadTelemetry.fromJson(_fixture('loadtelemetry_hems75_v3.json', 'telemetry'));
expect(tel.budget!.identityHolds, isTrue);
});
});
// ───────────────────────────────────────────────────────────────────────────
group('champs omis, jamais nuls', () {
test('timestamp absent = aucun cycle, pas un cycle daté de l\'époque zéro', () {
final tel = LoadTelemetry.fromJson({'degradedMode': false, 'loads': []});
expect(tel.hasNeverRun, isTrue);
expect(tel.timestamp, isNull);
expect(tel.ageAt(DateTime.now()), isNull);
});
test('budget absent = mode dégradé, pas un budget de zéro watt', () {
final tel = LoadTelemetry.fromJson({
'degradedMode': true,
'timestamp': '2026-08-25T16:47:00Z',
'loads': [],
});
expect(tel.degradedMode, isTrue);
expect(tel.budget, isNull);
});
test('lock, mechanism et faultCode absents ne fabriquent aucune valeur', () {
final e = LoadTelemetryEntry.fromJson({
'loadId': 'x',
'allocatedW': 0,
'available': true,
'decision': {'code': 'SURPLUS_INSUFFICIENT', 'params': {'budgetW': 12}},
});
expect(e.lock, isNull);
expect(e.mechanism, isNull);
expect(e.faultCode, isNull);
expect(e.inFault, isFalse);
});
});
// ───────────────────────────────────────────────────────────────────────────
group('§7-6 — fraîcheur', () {
LoadTelemetry at(String iso) => LoadTelemetry.fromJson({
'degradedMode': false,
'timestamp': iso,
'loads': [],
});
test('le seuil est supérieur au battement de cœur du plugin', () {
// Le plugin émet une notification au moins toutes les 60 s et le timestamp avance
// d'un cycle par minute (vérifié sur .75 : 12:37 → 12:38 → 12:39). Un seuil ≤ 60 s
// crierait « données anciennes » sur une installation parfaitement vivante.
expect(kTelemetryStaleAfter.inSeconds, greaterThan(60));
});
test('un cycle d\'il y a deux minutes n\'est pas encore ancien', () {
final now = DateTime.utc(2026, 8, 25, 16, 49);
expect(at('2026-08-25T16:47:00Z').ageAt(now), const Duration(minutes: 2));
expect(at('2026-08-25T16:47:00Z').ageAt(now)! > kTelemetryStaleAfter, isFalse);
});
test('un arbitre figé depuis dix minutes dépasse le seuil', () {
final now = DateTime.utc(2026, 8, 25, 16, 57);
expect(at('2026-08-25T16:47:00Z').ageAt(now)! > kTelemetryStaleAfter, isTrue);
});
test('un âge négatif (horloges désynchronisées) est ramené à zéro, pas au futur', () {
final now = DateTime.utc(2026, 8, 25, 16, 40);
expect(at('2026-08-25T16:47:00Z').ageAt(now), Duration.zero);
});
});
// ───────────────────────────────────────────────────────────────────────────
group('§7-7 — charge en défaut', () {
// Charge utile SYNTHÉTIQUE, mais recopiée de ce que `.75` a réellement publié le
// 2026-08-25 pendant le passage de `tools/rpc/fault_probe.dart` : une charge
// etmvariableload dont le ThingId n'existe pas. Le dump brut n'a pas été conservé
// (le défaut n'a pas été reprovoqué pour ça) — `--dump` le capturera au prochain
// passage.
LoadTelemetryEntry faulty() => LoadTelemetryEntry.fromJson({
'loadId': '{00000000-dead-4bee-8000-000000000042}',
'allocatedW': 0,
'available': false,
'faultCode': 'WRITE_FAILED',
'decision': {
'code': 'LOAD_UNAVAILABLE',
'params': {'frozenW': 0},
},
});
test('une charge indisponible se lit comme en défaut', () {
final e = faulty();
expect(e.inFault, isTrue);
expect(e.faultCode, 'WRITE_FAILED');
expect(e.allocatedW, 0);
});
test('le défaut et son motif se rendent en français, sans le code brut', () {
final e = faulty();
expect(faultText(t, e.faultCode!), contains('écriture'));
expect(decisionText(t, e.decision), isNot(contains('LOAD_UNAVAILABLE')));
});
test('le code de défaut prime sur l\'absence de mécanisme', () {
// Observé sur `.75` : le Thing manquant fait échouer l'écriture, l'échelle
// s'épuise, et `m_faulted` masque THING_MISSING. L'app affiche ce que la box dit,
// sans corriger le diagnostic à sa place.
final e = faulty();
expect(e.mechanism, isNull);
expect(faultText(t, e.faultCode!), isNot(faultText(t, 'THING_MISSING')));
});
});
// ───────────────────────────────────────────────────────────────────────────
group('§7-8 — rendu des codes, et repli sur l\'inconnu', () {
test('les motifs du catalogue se rendent en français avec leurs paramètres', () {
final phrase = decisionText(
t,
const LoadDecision(
code: 'SURPLUS_SETPOINT',
params: {'budgetW': 11897, 'setpointW': 3500, 'stepped': true}),
);
expect(phrase, contains('palier'));
expect(phrase, isNot(contains('SURPLUS_SETPOINT')));
});
test('`stepped` distingue deux phrases, et son absence ne prétend ni l\'une ni l\'autre',
() {
String render(Map<String, dynamic> p) =>
decisionText(t, LoadDecision(code: 'SURPLUS_SETPOINT', params: p));
final stepped = render({'budgetW': 100, 'setpointW': 50, 'stepped': true});
final plain = render({'budgetW': 100, 'setpointW': 50, 'stepped': false});
final absent = render({'budgetW': 100, 'setpointW': 50});
expect(stepped, isNot(plain));
expect(absent, plain);
});
test('un code absent de l\'ARB s\'affiche en repli lisible, sans plantage', () {
final phrase = decisionText(
t,
const LoadDecision(code: 'FUTURE_CODE_2027', params: {'budgetW': 42, 'a': true}),
);
// Une box en avance sur l'app est un cas normal en parc déployé.
expect(phrase, contains('FUTURE_CODE_2027'));
expect(phrase, contains('budgetW = 42'));
expect(phrase, isNot(isEmpty));
});
test('les paramètres du repli sont triés : deux rendus du même motif sont identiques',
() {
final a = formatParams({'zW': 1, 'aW': 2});
final b = formatParams({'aW': 2, 'zW': 1});
expect(a, b);
expect(a, ' (aW = 2, zW = 1)');
});
test('un code connu privé d\'un paramètre attendu bascule en repli, pas en phrase trouée',
() {
// « Consigne de W servie par le surplus » aurait l'air d'une donnée ; le code brut
// a l'air de ce qu'il est.
final phrase = decisionText(
t, const LoadDecision(code: 'SG_FORCED', params: {'budgetW': 10}));
expect(phrase, contains('SG_FORCED'));
});
test('tous les codes du catalogue fermé ont une phrase, aucun ne tombe en repli', () {
const catalogue = <String, Map<String, dynamic>>{
'EV_SURPLUS': {},
'EV_SPOT_MARKET': {},
'EV_DEADLINE': {},
'EV_ECO_MIN': {},
'EV_IDLE': {},
'LOAD_UNAVAILABLE': {'frozenW': 500},
'LOCK_MIN_ON': {'appliedW': 500, 'budgetW': 100},
'LOCK_MIN_OFF': {'budgetW': 100, 'requestedW': 500},
'LOCK_MIN_OFF_CAPPED': {'appliedW': 1, 'budgetW': 2, 'requestedW': 3},
'SURPLUS_SETPOINT': {'budgetW': 1, 'setpointW': 2, 'stepped': false},
'SURPLUS_INSUFFICIENT': {'budgetW': 229},
'LOCK_MIN_STATE_HOLD': {'state': 4},
'SG_FORCED': {'budgetW': 1, 'estimatedW': 3000},
'SG_RECOMMENDED': {'budgetW': 1, 'estimatedW': 1500},
'SG_NORMAL': {'budgetW': 1},
'DEGRADED_L2': {},
'SAFE_STATE_RELAY': {},
'SAFE_STATE_SETPOINT': {},
'SAFE_STATE_SG_READY': {},
};
for (final entry in catalogue.entries) {
final phrase =
decisionText(t, LoadDecision(code: entry.key, params: entry.value));
expect(phrase, isNot(contains(entry.key)),
reason: '${entry.key} tombe en repli alors qu\'il est au catalogue');
}
});
test('défauts, verrous et mécanismes ont eux aussi leur repli', () {
expect(faultText(t, 'WRITE_FAILED'), isNot(contains('WRITE_FAILED')));
expect(faultText(t, 'THING_MISSING'), isNot(contains('THING_MISSING')));
expect(faultText(t, 'UNUSABLE_ENCODING'), isNot(contains('UNUSABLE_ENCODING')));
expect(faultText(t, 'FUTURE_FAULT'), contains('FUTURE_FAULT'));
expect(lockText(t, const LoadLock(kind: 'minOff', remainingS: 60)), contains('60'));
expect(lockText(t, const LoadLock(kind: 'futureLock', remainingS: 5)),
contains('futureLock'));
expect(
mechanismText(t, LoadMechanism(const {'stageW': 3500, 'maxStageW': 3500}, 'relay')),
contains('3'));
expect(mechanismText(t, LoadMechanism(const {}, 'futureMechanism')),
contains('futureMechanism'));
});
});
}