etm-powersync-app/test/obligation_saisie_test.dart
Patrick Schurig cdffb9d804 feat(§10): l'avertissement de saisie — une consigne, pas une fatalité affichée
Lot configuration. La section « Obligation quotidienne » porte l'énergie due, son
échéance, et l'avertissement qui rend la déclaration honnête.

Pourquoi à la saisie : au résultat l'heure est passée, l'achat a eu lieu. Et sous
`unmeasurable` l'obligation ne peut JAMAIS être close — ni annoncée tenue (R6
interdit `ECO_FLOOR_MET`), ni chiffrée dans son manque (`missingWh` absent).
`ECO_FLOOR_MISSED` sort tous les jours par construction. Un mécanisme dont le
succès est inobservable et l'échec quotidien ne doit pas, en plus, dépenser en
silence.

Et c'est une consigne : les trois causes portent trois gestes — rattacher un
compteur (le plus fréquent, réparable), en mettre un qui totalise l'énergie, ou
constater que c'est le matériel. Une cause inconnue se dit sans inventer de
geste.

Le coût est CHIFFRÉ, pas estimé : le taux est constant depuis +etm55, donc le
total acheté vaut exactement la cible.

Trois silences délibérés, chacun testé : aucune obligation déclarée (personne ne
paiera), `progressMeasurable` absent (box antérieure à +etm49 — on ne conclut pas
d'une absence), et le nom du fuseau tant que l'app ne l'a pas lu. L'échéance dit
en revanche qu'elle est à l'heure de l'INSTALLATION, ce qui est vrai sans le
fuseau.

Revérifié à la sonde avant d'écrire, plutôt que cru : `progressMeasurable: true`
et `regime: measured` sortent bien sur la même charge — la contradiction du
2026-09-03 est fermée.

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

150 lines
6.6 KiB
Dart

/// **L'avertissement de saisie** — dire à la déclaration ce que l'obligation coûtera quand
/// la box ne peut pas la vérifier.
///
/// Pourquoi à la saisie et pas au résultat : au résultat, l'heure est passée et l'achat a eu
/// lieu. Et sous le régime `unmeasurable` l'obligation ne peut **jamais** être close — ni
/// annoncée tenue (R6 interdit `ECO_FLOOR_MET`), ni chiffrée dans son manque (`missingWh`
/// est absent, faute de savoir ce qui a été livré). `ECO_FLOOR_MISSED` sort donc tous les
/// jours par construction.
///
/// Le champ qui déclenche l'avertissement, `progressMeasurable`, a porté une contradiction
/// entre le 2026-08-30 et le 2026-09-03 : il disait ce que le MATÉRIEL permet pendant que le
/// régime disait ce que le MOTEUR fait, et l'avertissement serait resté muet exactement sur
/// les charges achetées à l'aveugle. Revérifié à la sonde le 2026-09-05 : les deux
/// s'accordent (`progressMeasurable: true` et `regime: measured` sur la même charge).
library;
import 'package:etm_powersync_app/l10n/app_localizations.dart';
import 'package:etm_powersync_app/providers/load_config_provider.dart';
import 'package:etm_powersync_app/providers/load_telemetry_provider.dart';
import 'package:etm_powersync_app/providers/schema_provider.dart';
import 'package:etm_powersync_app/screens/energy/load_mechanism_screen.dart';
import 'package:etm_powersync_app/services/nymea_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
/// Le chauffe-eau du banc, avec les champs de mesurabilité tels que `+etm49` les publie.
List<Map<String, dynamic>> _charge({
bool? mesurable = true,
String? cause,
int energie = 3000,
String echeance = '12:00',
}) =>
[
{
'id': 'chauffe-eau',
'label': 'chauffe-eau',
'adapter': 'relay-router',
'domain': 'ecs',
'mode': 'dynamic',
'priority': 1,
'enabled': true,
'relays': const [],
'needs': {'dailyDeadline': echeance, 'minEnergyWhPerDay': energie},
if (mesurable != null) 'progressMeasurable': mesurable,
if (cause != null) 'progressUnmeasurableCause': cause,
}
];
Widget _harness(List<Map<String, dynamic>> config) => MultiProvider(
providers: [
ChangeNotifierProvider.value(
value: LoadConfigProvider()..adoptForTest(config)),
ChangeNotifierProvider.value(
value: LoadTelemetryProvider()
..adoptForTest({'degradedMode': false, 'loads': const []})),
ChangeNotifierProvider.value(value: SchemaProvider()),
ChangeNotifierProvider.value(value: NymeaService()),
],
child: MaterialApp(
locale: const Locale('fr'),
localizationsDelegates: L10n.localizationsDelegates,
supportedLocales: L10n.supportedLocales,
home: const LoadMechanismScreen(loadId: 'chauffe-eau'),
),
);
/// L'écran est un `ListView` : hors viewport, rien n'est construit, et `find` rend une
/// absence qui se lit comme un succès. Grande surface obligatoire.
Future<void> _poser(WidgetTester tester, Widget w) async {
await tester.binding.setSurfaceSize(const Size(1000, 8000));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(w);
await tester.pumpAndSettle();
}
void main() {
group('l\'avertissement se déclenche sur ce que le MOTEUR fait', () {
testWidgets('mesurable : la section est là, l\'alarme non', (tester) async {
await _poser(tester, _harness(_charge()));
expect(find.text('Obligation quotidienne'), findsOneWidget);
expect(find.textContaining('ne pourra jamais être close'), findsNothing);
});
testWidgets('non mesurable : ce que ça coûte, et le geste qui le répare',
(tester) async {
await _poser(tester, _harness(_charge(mesurable: false, cause: 'noMeter')));
expect(find.textContaining('ne pourra jamais être close'), findsOneWidget);
// Le coût est CHIFFRÉ, pas estimé : taux constant depuis +etm55, donc le total
// acheté vaut exactement la cible.
expect(find.textContaining('3,0 kWh par jour'), findsOneWidget);
// Et surtout : une consigne, pas une fatalité.
expect(find.textContaining('Rattachez-en un'), findsOneWidget);
});
testWidgets('un compteur sans énergie n\'est pas « pas de compteur »',
(tester) async {
await _poser(
tester, _harness(_charge(mesurable: false, cause: 'noEnergyMeter')));
expect(find.textContaining('publie une puissance, pas une énergie'),
findsOneWidget);
expect(find.textContaining('Rattachez-en un'), findsNothing);
});
testWidgets('la borne sans énergie de session : c\'est le matériel',
(tester) async {
await _poser(
tester, _harness(_charge(mesurable: false, cause: 'noSessionEnergy')));
expect(find.textContaining('C\'est le matériel'), findsOneWidget);
// On n'envoie pas chercher un réglage qui n'existe pas.
expect(find.textContaining('Rattachez'), findsNothing);
});
});
group('quand il faut se taire', () {
testWidgets('aucune obligation déclarée : rien à avertir', (tester) async {
// Personne ne paiera : l'avertissement serait du bruit sur un écran qu'on ouvre
// pour toute autre chose.
await _poser(tester,
_harness(_charge(mesurable: false, cause: 'noMeter', energie: 0)));
expect(find.textContaining('ne pourra jamais être close'), findsNothing);
});
testWidgets('box antérieure à +etm49 : on ne conclut pas d\'une absence',
(tester) async {
// `progressMeasurable` absent ne veut pas dire « non mesurable ». Avertir ici
// serait fabriquer une alarme à partir d'un champ que la box ne publie pas.
await _poser(tester, _harness(_charge(mesurable: null)));
expect(find.textContaining('ne pourra jamais être close'), findsNothing);
});
testWidgets('cause inconnue : on le dit, on n\'invente pas de geste',
(tester) async {
await _poser(
tester, _harness(_charge(mesurable: false, cause: 'futureCause')));
expect(find.textContaining('futureCause'), findsOneWidget);
});
});
group('l\'échéance est à l\'heure de l\'installation', () {
testWidgets('et l\'écran le dit, plutôt que de choisir une horloge',
(tester) async {
await _poser(tester, _harness(_charge()));
expect(find.textContaining('Heure de l\'INSTALLATION'), findsOneWidget);
expect(find.textContaining('pas celle de cet appareil'), findsOneWidget);
});
});
}