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
278 lines
12 KiB
Dart
278 lines
12 KiB
Dart
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'));
|
||
});
|
||
});
|
||
}
|