`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
359 lines
16 KiB
Dart
359 lines
16 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('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'));
|
||
});
|
||
});
|
||
}
|