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 _fixture(String name, String key) { final raw = File('test/fixtures/$name').readAsStringSync(); return Map.from( (jsonDecode(raw) as Map)[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 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 = >{ '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 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(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': >[], }); 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); }); }); }