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('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}, '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')); }); }); }