R2 est livrée côté moteur (1.15.2+etm30 sur .75) : `funding` ne figure plus à la racine d'une entrée de `loads[]`, il vit dans `levels[]`, un par passe. La rupture est effective, pas seulement contractuelle — `entry.funding` est désormais `null` partout, donc la mention « servie au réseau » avait disparu de la carte et une charge achetée au réseau aurait été comptée comme surplus dans l'identité. Une panne muette : rien n'échoue, tout se lit de travers. - `LoadLevel` — une passe : `level`, `targetW` (du DÉCIDÉ, comme `allocatedW`), `funding` ; - `resolvedFunding` / `isGridFunded` résolvent les DEUX formes ; `levels[]` vide = box antérieure, pas « aucune passe » ; - `'mixed'` est NOMMÉ plutôt que replié : impossible aujourd'hui (une seule passe publiée), courant au §10, et l'affirmer d'un côté serait une demi-vérité ; - `surplusShareW` somme les `targetW` financés au surplus, l'exception écrite `EV_GRID_START` (part de surplus = `params.budgetW`) passant avant. Vérifié à la sonde contre `.75`, pas en test : 3 500 + 3 000 == 6 500 == `budget.allocatedW` au cycle 06:10, puis 1 500 == 1 500 à 06:14. Le niveau publié est « comfort », et c'est déjà le bon nom. Aucun écran à deux niveaux n'est commencé : le reste de la maquette attend R1 et R3 à R8, et le §10 n'est pas implémenté. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
765 lines
32 KiB
Dart
765 lines
32 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);
|
||
});
|
||
});
|
||
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
group('+etm30 — `funding` descend dans `levels[]` (R2)', () {
|
||
// Charge utile RELEVÉE sur `.75` le 2026-08-30, plugin `1.15.2+etm30`, cycle
|
||
// 06:10:00Z. Deux points la rendent utile telle quelle : aucune charge ne porte de
|
||
// `funding` à sa racine, et l'identité y est exerçable pour de vrai —
|
||
// 3 500 + 3 000 == 6 500 == `budget.allocatedW`.
|
||
Map<String, dynamic> payload() => {
|
||
'degradedMode': false,
|
||
'timestamp': '2026-08-30T06:10:00Z',
|
||
'budget': {
|
||
'surplusW': 1221.9,
|
||
'evReservedW': 0.0,
|
||
'recreditedW': 6500.0,
|
||
'allocatedW': 6500.0,
|
||
'remainingW': 1221.9,
|
||
},
|
||
'loads': [
|
||
{
|
||
'loadId': 'chauffe-eau',
|
||
'allocatedW': 3500.0,
|
||
'available': true,
|
||
'levels': [
|
||
{'level': 'comfort', 'funding': 'surplus', 'targetW': 3500.0},
|
||
],
|
||
'measurement': {'source': 'meter', 'powerW': 1500.0},
|
||
'mechanism': {'kind': 'relay', 'stageW': 3500.0, 'maxStageW': 3500.0},
|
||
'decision': {
|
||
'code': 'SURPLUS_SETPOINT',
|
||
'params': {'budgetW': 4722, 'setpointW': 3500, 'stepped': true},
|
||
},
|
||
},
|
||
{
|
||
'loadId': 'pac-terrain',
|
||
'allocatedW': 3000.0,
|
||
'available': true,
|
||
'levels': [
|
||
{'level': 'comfort', 'funding': 'surplus', 'targetW': 3000.0},
|
||
],
|
||
'measurement': {'source': 'meter', 'powerW': 800.0},
|
||
'mechanism': {'kind': 'sgReady', 'state': 4, 'estimatedPowerW': 3000.0},
|
||
'decision': {
|
||
'code': 'SG_FORCED',
|
||
'params': {'budgetW': 4222, 'estimatedW': 3000},
|
||
},
|
||
},
|
||
],
|
||
};
|
||
|
||
test('la racine ne porte plus rien, et le niveau porte tout', () {
|
||
final tel = LoadTelemetry.fromJson(payload());
|
||
final e = tel.entryFor('chauffe-eau')!;
|
||
|
||
// La rupture est effective : lue à la racine, la valeur a disparu.
|
||
expect(e.funding, isNull);
|
||
// Résolue, elle est là — c'est par là que doivent passer les écrans.
|
||
expect(e.resolvedFunding, 'surplus');
|
||
expect(e.isGridFunded, isFalse);
|
||
|
||
expect(e.levels.single.level, 'comfort');
|
||
expect(e.levels.single.targetW, 3500);
|
||
});
|
||
|
||
test('l\'identité tient sur la charge utile réelle de `.75`', () {
|
||
final tel = LoadTelemetry.fromJson(payload());
|
||
// Σ targetW des niveaux financés au surplus == budget.allocatedW, exactement.
|
||
expect(tel.surplusAllocatedW, 6500);
|
||
expect(tel.surplusIdentityHolds, isTrue);
|
||
|
||
// Et elle ne se confond pas avec Σ allocatedW : ici les deux coïncident parce
|
||
// qu'aucune charge n'est servie au réseau. C'est un cas, pas la règle.
|
||
expect(tel.loads.fold<double>(0, (a, e) => a + e.allocatedW), 6500);
|
||
});
|
||
|
||
test('un niveau à `targetW: 0` est une passe PARCOURUE, pas une absence', () {
|
||
// Relevé au cycle 06:14 : le chauffe-eau n'a rien reçu, et son niveau est publié
|
||
// quand même avec le motif qui dit pourquoi.
|
||
final e = LoadTelemetryEntry.fromJson(const {
|
||
'loadId': 'chauffe-eau',
|
||
'allocatedW': 0.0,
|
||
'available': true,
|
||
'levels': [
|
||
{'level': 'comfort', 'funding': 'surplus', 'targetW': 0.0},
|
||
],
|
||
'decision': {'code': 'SURPLUS_INSUFFICIENT', 'params': {}},
|
||
});
|
||
expect(e.levels, hasLength(1));
|
||
expect(e.resolvedFunding, 'surplus');
|
||
expect(e.surplusShareW, 0);
|
||
});
|
||
|
||
test('la forme antérieure continue de se lire', () {
|
||
// Box avant `+etm30` : pas de `levels`, un `funding` à la racine.
|
||
final e = LoadTelemetryEntry.fromJson(const {
|
||
'loadId': 'borne-echeance',
|
||
'allocatedW': 3680.0,
|
||
'available': true,
|
||
'funding': 'grid',
|
||
'decision': {'code': 'EV_DEADLINE', 'params': {}},
|
||
});
|
||
expect(e.levels, isEmpty);
|
||
expect(e.resolvedFunding, 'grid');
|
||
expect(e.isGridFunded, isTrue);
|
||
expect(e.surplusShareW, 0);
|
||
});
|
||
|
||
test('des niveaux discordants se NOMMENT `mixed`, ils ne se replient pas', () {
|
||
// Impossible aujourd'hui — une seule passe est publiée. Le §10 le rendra courant :
|
||
// plancher éco acheté au réseau, confort servi par le surplus, même cycle. Replier
|
||
// sur l'un des deux ferait affirmer à un écran un financement unique qui n'existe
|
||
// pas ; `isGridFunded` reste donc faux, et c'est un écran à deux niveaux qui
|
||
// traitera le cas.
|
||
final e = LoadTelemetryEntry.fromJson(const {
|
||
'loadId': 'chauffe-eau',
|
||
'allocatedW': 2400.0,
|
||
'available': true,
|
||
'levels': [
|
||
{'level': 'eco', 'funding': 'grid', 'targetW': 1400.0},
|
||
{'level': 'comfort', 'funding': 'surplus', 'targetW': 1000.0},
|
||
],
|
||
'decision': {'code': 'SURPLUS_SETPOINT', 'params': {}},
|
||
});
|
||
expect(e.resolvedFunding, 'mixed');
|
||
expect(e.isGridFunded, isFalse);
|
||
// Seul le niveau financé au surplus entre dans l'identité.
|
||
expect(e.surplusShareW, 1000);
|
||
});
|
||
|
||
test('`EV_GRID_START` viole l\'identité, et c\'est l\'exception ÉCRITE', () {
|
||
// Le motif partage une allocation entre les deux compteurs du budget : sa part de
|
||
// surplus est `budgetW`, pas `targetW`. L'exception est au contrat (INTERFACE.md)
|
||
// et le restera jusqu'à R3 — à lire comme telle, jamais comme une divergence.
|
||
final tel = LoadTelemetry.fromJson({
|
||
'degradedMode': false,
|
||
'timestamp': '2026-08-30T06:20:00Z',
|
||
'budget': {
|
||
'surplusW': 1200.0,
|
||
'evReservedW': 2480.0,
|
||
'recreditedW': 0.0,
|
||
'allocatedW': 1200.0,
|
||
'remainingW': 0.0,
|
||
},
|
||
'loads': [
|
||
{
|
||
'loadId': 'borne-v2c',
|
||
'allocatedW': 3680.0,
|
||
'available': true,
|
||
'levels': [
|
||
{'level': 'comfort', 'funding': 'grid', 'targetW': 3680.0},
|
||
],
|
||
'decision': {
|
||
'code': 'EV_GRID_START',
|
||
'params': {'budgetW': 1200, 'gridW': 2480},
|
||
},
|
||
},
|
||
],
|
||
});
|
||
final e = tel.entryFor('borne-v2c')!;
|
||
|
||
// La part de surplus vient du motif, pas du niveau…
|
||
expect(e.surplusShareW, 1200);
|
||
expect(e.levels.single.targetW, 3680);
|
||
// …ce qui fait retomber l'identité, précisément parce que l'exception est traitée.
|
||
expect(tel.surplusIdentityHolds, isTrue);
|
||
});
|
||
});
|
||
}
|