+etm23 supprime le second commandeur : adjustEvChargers() ne commande plus, l'arbitre est
seul (9 commandes à la borne, 9 par l'arbitre, 0 par le proxy). allocationIsCommand est donc
retiré, avec les branches qui en dépendaient — l'étiquette « l'arbitre a décidé X W » et sa
ligne d'avertissement redeviennent « Alloué X W », et l'écart mesuré/commandé se dit à
nouveau sur une borne.
Ce que le getter est remplacé par : rien, délibérément. Le garder à true partout inviterait
à lire « commande » comme « réalité ». Car ce qui devient vrai, c'est que personne d'autre
ne commande — pas que la borne fait ce qu'on lui dit. Un plafond matériel, un véhicule qui
refuse, un câble débranché feront toujours diverger les deux. La borne garde donc sa ligne
d'état matériel, non plus parce qu'un autre commandeur la contredirait, mais parce que
mechanism (chargingEnabled, currentA, pluggedIn) est le seul endroit où cet écart se lit.
Deux motifs neufs : EV_GRID_START {budgetW, floorW, gridW} et PHASE_LIMIT {limitW,
requiredW}. Le premier mène par les watts ACHETÉS — c'est ce chiffre qui fait de la ligne
une décision et non un effet de bord. Le second doit écarter le surplus explicitement :
deux causes, deux gestes, et confondre les deux envoie chercher du soleil là où il faut
lire un compteur. EV_GRID_START n'a JAMAIS été vu sur machine : la clé est prête, rien
n'est bâti autour.
Et une correction que le brief signale en passant, qui aurait cassé en silence la
réconciliation posée hier : EV_GRID_START est le seul motif dont l'allocation se PARTAGE
entre les deux compteurs du budget — budgetW au surplus, gridW au réseau, budgetW + gridW
== allocatedW. Filtrer sur funding == "surplus" comptait cette ligne pour zéro et creusait
un trou de la taille de budgetW. surplusShareW la traite à part ; sans budgetW il rend zéro
plutôt qu'une part devinée, un chiffre faux étant pire qu'un chiffre manquant dans un
contrôle d'identité.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016vrTVifar2GN5rtUh89Wkq
599 lines
25 KiB
Dart
599 lines
25 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 — la charge utile RÉELLE du plugin est rendue, pas le code', () {
|
||
// Le motif est publié depuis `+etm22` avec CES trois clés. Celles que l'app
|
||
// cherchait avant (`batteryLevel`, `threshold`) n'ont jamais existé côté plugin :
|
||
// le motif sortait donc en repli, c'est-à-dire en anglais brut, sur le seul cas que
|
||
// le client ne peut déduire d'aucune mesure.
|
||
final phrase = decisionText(
|
||
t,
|
||
const LoadDecision(
|
||
code: 'BATTERY_RESERVE',
|
||
params: {'socPercent': 47, 'reservePercent': 90, 'withheldW': 3200},
|
||
),
|
||
);
|
||
expect(phrase, isNot(contains('BATTERY_RESERVE')),
|
||
reason: 'le motif tombe en repli : les clés cherchées ne sont pas les bonnes');
|
||
expect(phrase, contains('47'));
|
||
expect(phrase, contains('90'));
|
||
// Le chiffre RETENU mène la phrase : c'est lui qui rend la cause lisible, là où un
|
||
// pourcentage de SOC se subit. Le plugin en fait la condition d'émission du motif.
|
||
expect(phrase, contains('3'));
|
||
expect(phrase.toLowerCase(), isNot(contains('aucun surplus')));
|
||
});
|
||
|
||
test('réserve batterie — les clés d\'AVANT ne réactivent pas la phrase par accident',
|
||
() {
|
||
// Un motif portant les anciennes clés n'a pas de quoi composer : il doit sortir en
|
||
// repli, pas en phrase amputée qui aurait l'air d'une donnée.
|
||
final phrase = decisionText(
|
||
t,
|
||
const LoadDecision(
|
||
code: 'BATTERY_RESERVE', params: {'batteryLevel': 50, 'threshold': 90}),
|
||
);
|
||
expect(phrase, contains('réserve batterie'),
|
||
reason: 'le code EST connu — c\'est la variante courte qui doit sortir');
|
||
expect(phrase, isNot(contains('50')));
|
||
expect(phrase, isNot(contains('90')));
|
||
});
|
||
|
||
test('réserve batterie — une box sans `withheldW` garde une phrase entière', () {
|
||
final phrase = decisionText(
|
||
t,
|
||
const LoadDecision(
|
||
code: 'BATTERY_RESERVE', params: {'socPercent': 47, 'reservePercent': 90}),
|
||
);
|
||
expect(phrase, contains('47'));
|
||
expect(phrase, contains('90'));
|
||
expect(phrase, isNot(contains('BATTERY_RESERVE')));
|
||
});
|
||
|
||
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 porte le code RÉELLEMENT publié par le plugin', () {
|
||
// Source d'autorité : `etm/types/decisionreason.h`, `DecisionCode::BatteryReserve`.
|
||
// Un crochet vide ne plantait pas — il rendait le motif en anglais brut, ce qui est
|
||
// pire : ça a l'air d'un défaut de la box.
|
||
expect(kCodesReserveBatterie, contains('BATTERY_RESERVE'));
|
||
});
|
||
|
||
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},
|
||
'BELOW_MIN_POWER': {'budgetW': 149, 'minPowerW': 1500},
|
||
'BATTERY_RESERVE': {
|
||
'socPercent': 47,
|
||
'reservePercent': 90,
|
||
'withheldW': 3200,
|
||
},
|
||
'EV_GRID_START': {'budgetW': 2070, 'floorW': 4140, 'gridW': 2070},
|
||
'PHASE_LIMIT': {'limitW': 3680, 'requiredW': 4140},
|
||
'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'));
|
||
});
|
||
});
|
||
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
group('3g-1 — `funding`, et les bornes entrent dans loads[]', () {
|
||
// Charge utile calquée sur `.75` en `+etm22` : une borne financée au surplus, une
|
||
// seconde financée au RÉSEAU par le proxy (échéance de départ).
|
||
Map<String, dynamic> payload() => {
|
||
'degradedMode': false,
|
||
'timestamp': '2026-08-27T05:23:00Z',
|
||
'budget': {
|
||
'surplusW': 2000.0,
|
||
'evReservedW': 3680.0,
|
||
'recreditedW': 0.0,
|
||
'allocatedW': 2500.0,
|
||
'remainingW': -500.0,
|
||
},
|
||
'loads': [
|
||
{
|
||
'loadId': 'chauffe-eau',
|
||
'allocatedW': 2500.0,
|
||
'available': true,
|
||
'funding': 'surplus',
|
||
'mechanism': {'kind': 'relay', 'stageW': 2500.0, 'maxStageW': 3500.0},
|
||
'decision': {
|
||
'code': 'SURPLUS_SETPOINT',
|
||
'params': {'budgetW': 2559, 'setpointW': 2500, 'stepped': true},
|
||
},
|
||
},
|
||
{
|
||
'loadId': 'borne-echeance',
|
||
'allocatedW': 3680.0,
|
||
'available': true,
|
||
'funding': 'grid',
|
||
'mechanism': {
|
||
'kind': 'evcharger',
|
||
'chargingEnabled': true,
|
||
'currentA': 16.0,
|
||
'phaseCount': 1,
|
||
'pluggedIn': true,
|
||
},
|
||
'decision': {'code': 'EV_DEADLINE', 'params': {}},
|
||
},
|
||
],
|
||
};
|
||
|
||
test('le financement se lit, et son absence n\'est pas un zéro', () {
|
||
final tel = LoadTelemetry.fromJson(payload());
|
||
expect(tel.entryFor('chauffe-eau')!.funding, 'surplus');
|
||
expect(tel.entryFor('borne-echeance')!.funding, 'grid');
|
||
|
||
// Omis en mode dégradé — sans plan, il n'y a pas de financement.
|
||
final sansPlan = LoadTelemetryEntry.fromJson(const {
|
||
'loadId': 'x',
|
||
'allocatedW': 0.0,
|
||
'available': true,
|
||
});
|
||
expect(sansPlan.funding, isNull);
|
||
// …et « omis » se replie sur surplus : c'est le seul financement qui existait avant
|
||
// ce champ, donc l'identité reste vérifiable sur une box antérieure.
|
||
expect(sansPlan.isSurplusFunded, isTrue);
|
||
});
|
||
|
||
test('sommer sans filtrer sur `funding` casse l\'identité du budget', () {
|
||
final tel = LoadTelemetry.fromJson(payload());
|
||
|
||
// La somme BRUTE compte la borne servie au réseau et ne retombe sur rien.
|
||
final brute = tel.loads.fold<double>(0, (a, e) => a + e.allocatedW);
|
||
expect(brute, 6180);
|
||
expect(brute, isNot(closeTo(tel.budget!.allocatedW, 1)));
|
||
|
||
// Filtrée, elle retombe exactement sur `budget.allocatedW` — et la puissance de la
|
||
// borne se retrouve, elle, dans `budget.evReservedW`.
|
||
expect(tel.surplusAllocatedW, 2500);
|
||
expect(tel.surplusIdentityHolds, isTrue);
|
||
expect(tel.budget!.evReservedW, 3680);
|
||
});
|
||
|
||
test('sans budget, l\'identité ne rend pas « faux » mais « rien à réconcilier »', () {
|
||
final degrade = LoadTelemetry.fromJson(const {
|
||
'degradedMode': true,
|
||
'loads': <Map<String, dynamic>>[],
|
||
});
|
||
expect(degrade.surplusIdentityHolds, isNull);
|
||
});
|
||
|
||
test('commander n\'est pas obtenir — l\'écart se lit dans mechanism, pas dans '
|
||
'allocatedW', () {
|
||
// 3g-2 a supprimé le second commandeur : l'allocation d'une borne EST un ordre.
|
||
// Ce qui reste, et que la levée de la réserve ne doit pas emporter : un plafond
|
||
// matériel, un véhicule qui refuse ou un câble débranché font toujours diverger
|
||
// l'ordre et la réalité. Ici, commandée à 4 140 W, la borne ne tire rien parce
|
||
// qu'aucun véhicule n'est branché.
|
||
final borne = LoadTelemetryEntry.fromJson(const {
|
||
'loadId': 'borne-surplus',
|
||
'allocatedW': 4140.0,
|
||
'available': true,
|
||
'funding': 'surplus',
|
||
'mechanism': {
|
||
'kind': 'evcharger',
|
||
'chargingEnabled': false,
|
||
'currentA': 0.0,
|
||
'phaseCount': 3,
|
||
'pluggedIn': false,
|
||
},
|
||
'decision': {
|
||
'code': 'SURPLUS_SETPOINT',
|
||
'params': {'budgetW': 4200, 'setpointW': 4140, 'stepped': false},
|
||
},
|
||
});
|
||
expect(borne.allocatedW, 4140, reason: 'ce qui a été COMMANDÉ');
|
||
expect(borne.mechanism!.chargingEnabled, isFalse, reason: 'ce qui SE PASSE');
|
||
expect(borne.mechanism!.pluggedIn, isFalse, reason: 'et pourquoi');
|
||
});
|
||
});
|
||
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
group('3g-2 — démarrage soutiré, et la comptabilité qui se partage', () {
|
||
test('EV_GRID_START nomme les watts ACHETÉS, pas seulement le manque', () {
|
||
final phrase = decisionText(
|
||
t,
|
||
const LoadDecision(
|
||
code: 'EV_GRID_START',
|
||
params: {'budgetW': 2070, 'floorW': 4140, 'gridW': 2070},
|
||
),
|
||
);
|
||
expect(phrase, isNot(contains('EV_GRID_START')));
|
||
expect(phrase, contains('réseau'),
|
||
reason: 'sans le mot, la ligne se lit comme un démarrage ordinaire');
|
||
});
|
||
|
||
test('PHASE_LIMIT ne se lit pas « il manque du soleil »', () {
|
||
final phrase = decisionText(
|
||
t,
|
||
const LoadDecision(
|
||
code: 'PHASE_LIMIT', params: {'limitW': 3680, 'requiredW': 4140}),
|
||
);
|
||
expect(phrase, isNot(contains('PHASE_LIMIT')));
|
||
expect(phrase.toLowerCase(), contains('phase'));
|
||
// Deux causes, deux gestes : la phrase doit écarter explicitement le surplus.
|
||
expect(phrase.toLowerCase(), contains('surplus'));
|
||
});
|
||
|
||
test('un démarrage soutiré PARTAGE son allocation entre les deux compteurs', () {
|
||
// C'est le seul cas où une même allocatedW alimente budget.allocatedW ET
|
||
// budget.evReservedW. La compter en bloc d'un côté ou de l'autre casse la
|
||
// réconciliation sans rien dire.
|
||
final tel = LoadTelemetry.fromJson(const {
|
||
'degradedMode': false,
|
||
'timestamp': '2026-08-27T12:00:00Z',
|
||
'budget': {
|
||
'surplusW': 2500.0,
|
||
'evReservedW': 2070.0,
|
||
'recreditedW': 0.0,
|
||
'allocatedW': 2070.0,
|
||
'remainingW': 430.0,
|
||
},
|
||
'loads': [
|
||
{
|
||
'loadId': 'borne-appoint',
|
||
'allocatedW': 4140.0,
|
||
'available': true,
|
||
'funding': 'grid',
|
||
'mechanism': {'kind': 'evcharger', 'chargingEnabled': true},
|
||
'decision': {
|
||
'code': 'EV_GRID_START',
|
||
'params': {'budgetW': 2070, 'floorW': 4140, 'gridW': 2070},
|
||
},
|
||
},
|
||
],
|
||
});
|
||
final e = tel.loads.single;
|
||
// budgetW + gridW == allocatedW, garanti par le plugin.
|
||
expect(e.decision.numParam('budgetW')! + e.decision.numParam('gridW')!,
|
||
e.allocatedW);
|
||
expect(e.surplusShareW, 2070, reason: 'seule la part surplus se réconcilie');
|
||
expect(tel.surplusIdentityHolds, isTrue);
|
||
});
|
||
|
||
test('sans budgetW, la part surplus retombe à zéro plutôt que sur une devinette', () {
|
||
final e = LoadTelemetryEntry.fromJson(const {
|
||
'loadId': 'borne',
|
||
'allocatedW': 4140.0,
|
||
'available': true,
|
||
'funding': 'grid',
|
||
'decision': {'code': 'EV_GRID_START', 'params': {}},
|
||
});
|
||
expect(e.surplusShareW, 0);
|
||
});
|
||
|
||
test('une borne servie au réseau SANS partage ne compte pas au surplus', () {
|
||
final e = LoadTelemetryEntry.fromJson(const {
|
||
'loadId': 'borne-echeance',
|
||
'allocatedW': 3680.0,
|
||
'available': true,
|
||
'funding': 'grid',
|
||
'decision': {'code': 'EV_DEADLINE', 'params': {}},
|
||
});
|
||
expect(e.surplusShareW, 0);
|
||
});
|
||
});
|
||
}
|