etm-powersync-app/test/load_telemetry_test.dart
Patrick Schurig a1ffd13107 feat(télémétrie): la réconciliation repose sur counts — l'exception EV_GRID_START tombe
`+etm32` publie `counts` par niveau : où chaque watt est compté, et combien.
Vérifié à la sonde sur `.75` avant de toucher au code — `counts` sur tous les
niveaux, destination publiée même à zéro, et les DEUX identités qui tiennent.

- `LoadLevel.counts`, `countsTotalW`, `countsCoherent`, `partSurplusW` ;
- `surplusShareW` LIT `counts["budget.allocatedW"]` au lieu d'interpréter ;
- `soutirageShareW` + `LoadTelemetry.soutirageIdentityHolds` : la seconde
  identité, qui n'existait pas ce matin faute de registre pour les watts achetés ;
- `LoadTelemetry.drawCommittedW` — absent = box antérieure à `+etm31`, jamais
  « rien acheté » ;
- `destinationsInconnues` : une clé née après cette app est NOMMÉE, jamais
  comptée zéro, et l'identité par niveau tient sans connaître le sens des clés.

L'exception au motif survit exactement là où la box ne sait pas dire le partage
autrement — formes sans `counts`. Ce n'est pas une version qui l'a retirée, c'est
une charge utile qui l'a rendue inutile ; sur une installation déjà déployée, la
retirer casserait la réconciliation.

Le rapport de bogue porte désormais les deux identités, les destinations
inconnues et les niveaux incohérents (Σ counts != targetW) — une incohérence de
la box, pas un arrondi.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-08-30 10:51:10 +02:00

882 lines
37 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 — 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('sans `counts`, l\'exception au motif survit — box +etm30/+etm31', () {
// La box partage l'allocation mais ne sait pas le dire : seul le motif le porte.
final tel = LoadTelemetry.fromJson({
'degradedMode': false,
'timestamp': '2026-08-30T06:20:00Z',
'budget': {
'surplusW': 900.0, 'evReservedW': 480.0, 'recreditedW': 0.0,
'allocatedW': 900.0, 'remainingW': 0.0,
},
'loads': [
{
'loadId': 'borne-v2c',
'allocatedW': 1380.0,
'available': true,
'levels': [
{'level': 'comfort', 'funding': 'grid', 'targetW': 1380.0},
],
'decision': {
'code': 'EV_GRID_START',
'params': {'budgetW': 900, 'floorW': 1380, 'gridW': 480},
},
},
],
});
final e = tel.entryFor('borne-v2c')!;
expect(e.surplusShareW, 900); // lu dans params, faute de mieux
expect(tel.surplusIdentityHolds, isTrue);
// Et le côté réseau n'est réconciliable par rien : pas de registre.
expect(e.soutirageShareW, isNull);
expect(tel.soutirageIdentityHolds, isNull);
});
});
// ───────────────────────────────────────────────────────────────────────────
group('+etm32 — `counts` : le registre, et la fin de l\'exception (R3)', () {
// Trace de la suite de simulation du moteur (brief §10), forme confirmée sur `.75`
// le 2026-08-30 en `1.15.2+etm32`.
Map<String, dynamic> partage() => {
'degradedMode': false,
'timestamp': '2026-08-30T08:46:00Z',
'budget': {
'surplusW': 900.0, 'evReservedW': 0.0, 'recreditedW': 0.0,
'allocatedW': 900.0, 'remainingW': 0.0,
},
'draw': {'committedW': 480.0},
'loads': [
{
'loadId': 'borne-v2c',
'allocatedW': 1380.0,
'available': true,
'levels': [
{
'level': 'comfort',
'funding': 'grid',
'targetW': 1380.0,
'counts': {'budget.allocatedW': 900.0, 'draw.committedW': 480.0},
},
],
'decision': {
'code': 'EV_GRID_START',
'params': {'budgetW': 900, 'floorW': 1380, 'gridW': 480},
},
},
],
};
test('les deux moitiés se LISENT, et les deux identités tiennent', () {
final tel = LoadTelemetry.fromJson(partage());
final e = tel.entryFor('borne-v2c')!;
// La part de surplus ne vient plus du motif : elle est lue dans le registre.
expect(e.surplusShareW, 900);
expect(e.soutirageShareW, 480);
expect(tel.surplusIdentityHolds, isTrue);
expect(tel.soutirageIdentityHolds, isTrue); // n'existait pas avant +etm32
// L'identité par niveau, vérifiable SANS connaître le sens des clés.
expect(e.levels.single.countsCoherent, isTrue);
expect(e.levels.single.countsTotalW, 1380);
expect(tel.niveauxIncoherents, isEmpty);
// `funding` reste l'origine et ne se dérive pas du registre : « grid » l'emporte
// dès qu'un watt est acheté, même quand la majorité vient du surplus.
expect(e.resolvedFunding, 'grid');
expect(e.isGridFunded, isTrue);
});
test('une destination inconnue est NOMMÉE, jamais comptée zéro', () {
final p = partage();
(p['loads'] as List)[0]['levels'][0]['counts'] = {
'budget.allocatedW': 900.0,
'draw.futureCounterW': 480.0, // registre né après cette app
};
final tel = LoadTelemetry.fromJson(p);
final e = tel.entryFor('borne-v2c')!;
expect(e.destinationsInconnues, {'draw.futureCounterW'});
// L'identité par niveau tient quand même : elle ne dépend pas du sens des clés.
expect(e.levels.single.countsCoherent, isTrue);
// …et les 480 W ne sont attribués à aucun compteur connu, surtout pas au surplus.
expect(e.surplusShareW, 900);
expect(e.soutirageShareW, 0);
});
test('`draw` publié à zéro avec sa clé — absent = box antérieure', () {
// Mesuré sur `.75` : une charge qui n'a rien reçu porte quand même sa destination.
final tel = LoadTelemetry.fromJson({
'degradedMode': false,
'timestamp': '2026-08-30T08:46:00Z',
'budget': {
'surplusW': -3029.4, 'evReservedW': 0.0, 'recreditedW': 1200.0,
'allocatedW': 1200.0, 'remainingW': -3029.4,
},
'draw': {'committedW': 0.0},
'loads': [
{
'loadId': 'chauffe-eau',
'allocatedW': 0.0,
'available': true,
'levels': [
{
'level': 'comfort', 'funding': 'surplus', 'targetW': 0.0,
'counts': {'budget.allocatedW': 0.0},
},
],
'decision': {'code': 'SURPLUS_INSUFFICIENT', 'params': {}},
},
{
'loadId': 'pac-terrain',
'allocatedW': 1200.0,
'available': true,
'levels': [
{
'level': 'comfort', 'funding': 'surplus', 'targetW': 1200.0,
'counts': {'budget.allocatedW': 1200.0},
},
],
'decision': {'code': 'SG_NORMAL', 'params': {}},
},
],
});
expect(tel.drawCommittedW, 0);
expect(tel.soutirageIdentityHolds, isTrue); // 0 == 0, et c'est vérifié, pas supposé
expect(tel.surplusAllocatedW, 1200);
expect(tel.surplusIdentityHolds, isTrue);
expect(tel.entryFor('chauffe-eau')!.levels.single.counts, {'budget.allocatedW': 0.0});
// `draw` absent ne vaut pas zéro : c'est une box antérieure à `+etm31`.
final ancienne = LoadTelemetry.fromJson({
'degradedMode': false, 'timestamp': '2026-08-30T08:46:00Z', 'loads': [],
});
expect(ancienne.drawCommittedW, isNull);
expect(ancienne.soutirageIdentityHolds, isNull);
});
});
}