etm-powersync-app/test/derogation_test.dart
Patrick Schurig b0b6666bc8 feat(ev): le boost est une DÉROGATION — bouton d'action, durée obligatoire, six fins
Le lot complet côté app, écrit sur le contrat vérifié dans Introspect sur .75 en
1.15.2+etm61 : SetLoadDerogation { loadId, durationS, o:targetWh } /
ClearLoadDerogation { loadId }, et derogationEnded[] { cause, loadId,
o:restoredMode } au niveau de la trame.

CE QUI EST UN BOUTON D'ACTION, PAS UN MODE. Une dérogation se superpose au mode
courant et s'efface ; elle ne le remplace pas. La mettre dans le sélecteur
ferait croire qu'on en sort en choisissant autre chose.

UNE DURÉE, TOUJOURS. Bornée [60, 21600] côté moteur. L'écran n'offre AUCUNE
option « jusqu'à annulation » — pas parce que l'appel échouerait, mais parce que
la promesse serait mauvaise : une dérogation qui ne sait pas dire quand elle
finit ne devrait pas exister (LM-1500). Le filet reste armé même avec une cible,
parce qu'une cible repose sur un instrument et qu'un instrument peut se taire.

L'ÉTAT NE SE LIT PAS DANS LE MODE. Si la borne était en manuel, le moteur force
Eco le temps de la dérogation : GetChargingInfos montrerait alors un mode que
personne n'a choisi. La vérité est sur la charge —
LoadTelemetryEntry.enDerogation.

Trois refus d'inventer, chacun figé par un test :
- entrée de télémétrie absente -> le bouton se TAIT. « Pas d'entrée » n'est pas
  « pas de boost », c'est « on ne sait pas » ;
- échéance dépassée alors que la box annonce encore la dérogation -> on la NOMME
  au lieu de rabattre à « 00:00 » : c'est une contradiction entre deux champs de
  la même trame, et le zéro l'effacerait ;
- cause de fin inconnue -> aucun libellé. Une box plus récente peut en publier
  une septième ; lui coller la phrase d'une autre serait pire.

Et `EnergyErrorInvalidParameter` sur l'arrêt ne crie pas au refus : la
dérogation a pu finir seule pendant l'appui, et une course que l'utilisateur ne
peut pas éviter n'est pas une erreur à lui montrer.

⚠️ boundBy a DEUX catalogues : drawCap|loadCeiling sur la dérogation, mais
« minimum » sur ECO_FLOOR_GRID — relevé sur la V2C aujourd'hui. Même nom, sens
différent : aucune traduction partagée.

⚠️ Écrit d'après le contrat, PAS observé : DEROGATION_ACTIVE, endsAt et boundBy
ne sont pas dans Introspect (les params d'une décision sont une carte libre), et
aucune dérogation n'a tourné pendant une sonde. Tous les champs restent
nullables. Noté aussi qu'Introspect SOUS-DÉCLARE — il annonce draw{committedW}
seul alors que la box publie authorisedW/binding/bindingPhase/perPhaseBound —
donc son silence ne prouve rien.

La feuille de choix défile : 47 px de débordement trouvés par le test sur une
surface courte, donc réels sur un téléphone à police agrandie.

358 tests.

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

128 lines
5.3 KiB
Dart

/// **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);
});
});
}