/// **La dérogation — le vrai boost, et il n'est pas un mode.** /// /// Écrite d'après le contrat du moteur (`+etm61`, `SetLoadDerogation` / /// `ClearLoadDerogation` vérifiées dans `Introspect` sur `.75`). Aucune dérogation n'a /// encore tourné pendant une sonde : ces tests figent la LECTURE du contrat, pas une /// observation. C'est écrit ici pour que personne ne les prenne pour une preuve de terrain. library; import 'package:etm_powersync_app/models/load_telemetry.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { group('on ne lit pas le MODE pour savoir si un boost tourne', () { test('la dérogation se lit sur la décision de la CHARGE', () { final d = LoadDecision.fromJson({ 'code': 'DEROGATION_ACTIVE', 'params': { 'endsAt': '2026-09-08T14:30:00Z', 'maxW': 7400.0, 'gridW': 7400.0, 'boundBy': 'drawCap', }, }); final dero = Derogation.depuis(d)!; expect(dero.maxW, 7400); expect(dero.gridW, 7400); expect(dero.boundBy, 'drawCap'); // Un instant se rend chez le lecteur — comme partout ailleurs. expect(dero.endsAt!.isUtc, isFalse); expect(dero.endsAt!.isAtSameMomentAs(DateTime.utc(2026, 9, 8, 14, 30)), isTrue); }); test('toute autre décision ne donne AUCUNE dérogation', () { final d = LoadDecision.fromJson({ 'code': 'ECO_FLOOR_GRID', // ⚠️ `ECO_FLOOR_GRID` porte AUSSI un `boundBy`, et son vocabulaire est différent : // relevé « minimum » sur la V2C le 2026-09-08. Deux champs de même nom, deux // catalogues — les confondre ferait lire un boost là où il y a un plancher. 'params': {'boundBy': 'minimum', 'minAlwaysW': 4140.0}, }); expect(Derogation.depuis(d), isNull); }); }); group('le compte à rebours ne se rabat pas à zéro en silence', () { final fin = DateTime.utc(2026, 9, 8, 14, 30); Derogation dero() => Derogation(endsAt: fin.toLocal()); test('avant le terme, il reste du temps', () { final r = dero().restant(maintenant: fin.toLocal().subtract(const Duration(minutes: 5))); expect(r, const Duration(minutes: 5)); expect(dero().depasse(maintenant: fin.toLocal().subtract(const Duration(minutes: 5))), isFalse); }); test('après le terme, l\'incohérence se NOMME', () { // Une échéance dépassée pendant que la box annonce encore la dérogation active // n'est pas « un compte à rebours fini » : c'est une contradiction entre deux champs // de la même trame. La rabattre à « 00:00 » l'effacerait. final apres = fin.toLocal().add(const Duration(minutes: 2)); expect(dero().depasse(maintenant: apres), isTrue); expect(dero().restant(maintenant: apres)!.isNegative, isTrue); }); test('sans échéance publiée, il n\'y a pas de compte à rebours — et pas zéro', () { const d = Derogation(maxW: 7400); expect(d.restant(), isNull); expect(d.depasse(), isFalse); }); }); group('six causes de fin, six phrases', () { test('chacune dit ce qu\'elle demande au lecteur', () { const causes = { 'targetReached': 'Cible atteinte.', 'durationElapsed': 'Durée écoulée.', 'unplugged': 'Véhicule débranché.', 'cancelled': 'Annulée.', 'modeChanged': 'Un mode a été choisi pendant la dérogation.', }; for (final e in causes.entries) { expect(DerogationEnded(loadId: 'x', cause: e.key).phrase, e.value); } // La seule qui appelle une VÉRIFICATION plutôt qu'un constat. expect(const DerogationEnded(loadId: 'x', cause: 'measurementLost').phrase, contains('vérifiez')); }); test('une cause INCONNUE ne reçoit pas de libellé inventé', () { // Une box plus récente que l'app peut en publier une septième. Lui coller la phrase // d'une autre serait pire que de dire « fin non reconnue ». expect(const DerogationEnded(loadId: 'x', cause: 'somethingNew').phrase, isNull); }); test('`restoredMode` absent sur un changement de mode — et c\'est voulu', () { // On ne restitue pas : ce serait écraser le mode que l'utilisateur vient de choisir. final e = DerogationEnded.fromJson( {'loadId': 'x', 'cause': 'modeChanged'}); expect(e.restoredMode, isNull); final f = DerogationEnded.fromJson( {'loadId': 'x', 'cause': 'durationElapsed', 'restoredMode': 0}); expect(f.restoredMode, 0); }); }); group('la trame porte la fin, pas la charge', () { test('`derogationEnded` se lit au niveau de la TRAME', () { // Au débranchement, la charge n'est déjà plus dans `loads[]` : chercher la fin // dedans ne la trouverait jamais, précisément dans le cas où elle compte le plus. final t = LoadTelemetry.fromJson({ 'degradedMode': false, 'loads': const [], 'derogationEnded': [ {'loadId': 'v2c', 'cause': 'unplugged'} ], }); expect(t.loads, isEmpty); expect(t.derogationEnded, hasLength(1)); expect(t.derogationEnded.first.cause, 'unplugged'); }); test('absente, la liste est vide — pas nulle', () { final t = LoadTelemetry.fromJson({'degradedMode': false, 'loads': const []}); expect(t.derogationEnded, isEmpty); }); }); }