etm-powersync-app/test/load_telemetry_test.dart
Patrick Schurig 801ba15094 feat(3g): la réserve batterie sort de l'usine — un seuil qui peut tout geler doit se voir
`batteryLevelConsideration` ne gouverne aujourd'hui que la recharge du véhicule. Une
fois la règle montée dans le budget, il gouvernera TOUTE l'installation : sous le
seuil le budget de surplus est annulé — pas réduit — donc ni ECS, ni PAC, ni bornes.

Le défaut d'usine est 0,9. Le banc `.75` est exactement dans ce cas : seuil 0,9,
batterie à 50 %. Le symptôme chez un client serait le pire qui soit — un HEMS qui ne
pilote plus rien par grand soleil, sans erreur, sans panne, rien à lire dans un
journal. Un paramètre qui peut produire ça n'est pas un paramètre d'usine.

- `BatteryReserveCard` lit et écrit le réglage, et ALERTE quand le seuil dépasse le SOC
  réellement atteint — le cas silencieux. Elle n'invente aucune valeur par défaut : si
  la box ne l'expose pas, la carte se tait plutôt que d'afficher un curseur mort.
- La portée future est dite sur la carte : régler ce seuil « pour la voiture »
  couperait le reste demain.
- Clé ARB `decisionBatteryReserve` posée, et `kCodesReserveBatterie` volontairement
  VIDE côté `telemetry_text` : le crochet attend le code que publiera le moteur, on ne
  devine pas son nom.

Vérifié sur appareil contre `.75` : réglage lu (0.9 / SOC 50 %), carte affichée,
campagne verte. L'assertion des cartes de charge fait désormais DÉFILER avant de
compter — dans un `ListView` ce qui n'est pas à l'écran n'est pas construit, et
chercher sans défiler ne prouvait que la hauteur de l'écran.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff
2026-08-26 12:01:30 +02:00

359 lines
16 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('BELOW_MIN_POWER — deux variantes, départagées par `state`', () {
// Premier motif ajouté depuis que le catalogue existe. Le sens porté est précis :
// il Y A du surplus, mais pas au bon format.
final watts = decisionText(
t,
const LoadDecision(
code: 'BELOW_MIN_POWER',
params: {'budgetW': 320, 'minPowerW': 500}),
);
final etats = decisionText(
t,
const LoadDecision(
code: 'BELOW_MIN_POWER',
params: {'budgetW': 900, 'minPowerW': 1500, 'state': 3}),
);
expect(watts, contains('palier'));
expect(watts, isNot(contains('BELOW_MIN_POWER')));
expect(etats, contains('état 3'));
expect(etats, isNot(watts));
});
test('BELOW_MIN_POWER ne se confond pas avec « il n\'y a rien »', () {
// SURPLUS_INSUFFICIENT et SG_NORMAL ne sortent plus que pour un budget ≤ 0. Les
// confondre ferait chercher un défaut de production là où il n'y a qu'une
// granularité de commande.
final rien = decisionText(
t, const LoadDecision(code: 'SURPLUS_INSUFFICIENT', params: {'budgetW': 0}));
final malForme = decisionText(
t,
const LoadDecision(
code: 'BELOW_MIN_POWER', params: {'budgetW': 320, 'minPowerW': 500}));
expect(rien, isNot(malForme));
expect(rien.toLowerCase(), contains('aucun surplus'));
});
test('le repli AURAIT tenu sur ce motif — code connu, paramètre manquant', () {
// C'est exactement le cas que le repli couvre, et le premier vrai à se présenter :
// une box en avance sur l'app. Sans `minPowerW`, on ne fabrique pas une phrase
// approximative — on rend le code et ses paramètres.
final phrase = decisionText(
t,
const LoadDecision(code: 'BELOW_MIN_POWER', params: {'budgetW': 320}),
);
expect(phrase, contains('BELOW_MIN_POWER'));
expect(phrase, contains('budgetW = 320'));
});
test('réserve batterie — le repli ne peut PAS se lire « aucun surplus »', () {
// Le motif viendra ; son code n'est pas tranché, donc rien ne l'attrape encore. Ce
// qui compte d'ici là est que le repli ne fabrique aucune confusion : le cas est
// « il y a du surplus, une règle l'interdit », et le lire comme « il n'y en a pas »
// enverrait chercher un défaut de production.
final repli = decisionText(
t,
const LoadDecision(
code: 'BATTERY_RESERVE_HOLD',
params: {'batteryLevel': 50, 'threshold': 90}),
);
expect(repli, contains('BATTERY_RESERVE_HOLD'));
expect(repli.toLowerCase(), isNot(contains('aucun surplus')));
expect(repli, contains('batteryLevel = 50'));
});
test('la phrase de la réserve nomme la CAUSE, pas l\'effet', () {
// Elle est écrite et relue d'avance : c'est la partie difficile, et la brancher le
// jour venu ne doit pas être l'occasion de la rédiger dans l'urgence.
final avec = t.decisionBatteryReserve(50, 90);
expect(avec, contains('réserve batterie'));
expect(avec, contains('surplus'));
expect(avec.toLowerCase(), isNot(startsWith('rien')),
reason: 'décrire l\'effet ferait chercher une panne');
expect(t.decisionBatteryReserveShort, contains('réserve batterie'));
});
test('le crochet du motif est VIDE tant que le code n\'est pas publié', () {
// Y mettre une supposition créerait une branche morte, ou pire une branche qui
// s'activerait sur un homonyme.
expect(kCodesReserveBatterie, isEmpty);
});
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'));
});
});
}