etm-powersync-app/test/obligation_saisie_test.dart
Patrick Schurig 71bb26c81b feat(saisie): l'horloge se qualifie, elle ne s'offre pas en réglage
« 12:00 heure de la box (Europe/London) — pas celle de cet appareil », et non
« Fuseau : Europe/Paris » : sous un champ, un intitulé de réglage laisse croire
qu'on choisit le fuseau DE CETTE échéance. Il n'y en a qu'un, il est système, et
il déplace d'un coup toutes les heures murales de l'installation — échéances,
frontière de la journée de reporting, et demain les plages tarifaires
(LM-1005-b-iii).

Affichage CONDITIONNEL : seulement quand les deux horloges divergent. C'est la
règle déjà appliquée deux fois cette semaine — ligne des ratios instantanés,
avertissement « partiel » du bilan — et sa raison tient en une phrase : un texte
permanent cesse d'être lu le jour exact où il devient vrai.

La divergence se DÉDUIT de ce que la box publie, sans base de fuseaux :
`periodStartedAt` est minuit de l'installation en epoch, donc rendu dans le
fuseau de l'appareil il tombe sur 00:00 si et seulement si les deux partagent le
même décalage. Exact pour l'instant présent, y compris les jours à 23 ou 25
heures — et c'est justement pourquoi nous avions demandé cette frontière plutôt
qu'un nom IANA.

Quatre silences testés : fuseaux identiques, échéance vide, box qui ne publie pas
la date, et l'intitulé « Fuseau : » qui ne doit jamais apparaître.

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

196 lines
8.7 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/models/energy_ratios_snapshot.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,
{EnergyRatiosSnapshot? ratios}) =>
MultiProvider(
providers: [
ChangeNotifierProvider.value(
value: LoadConfigProvider()..adoptForTest(config)),
ChangeNotifierProvider.value(
value: LoadTelemetryProvider()
..adoptForTest({'degradedMode': false, 'loads': const []})),
ChangeNotifierProvider.value(value: SchemaProvider()),
ChangeNotifierProvider.value(
value: ratios == null
? NymeaService()
: (NymeaService()..adoptRatiosForTest(ratios))),
],
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\'horloge se QUALIFIE, elle ne s\'offre pas en réglage', () {
// « Fuseau : Europe/Paris » sous ce champ laisserait croire qu'on règle le fuseau de
// CETTE échéance. Il n'y en a qu'un, il est système, et il déplace d'un coup toutes les
// heures murales de l'installation — d'où une phrase qui dit l'heure lue.
EnergyRatiosSnapshot _instantane({required bool divergent}) =>
EnergyRatiosSnapshot.fromJson({
'autonomyRate': 54.0,
'selfConsumptionRate': 52.0,
// Minuit de l'installation, en epoch. Rendu dans le fuseau de l'appareil, il tombe
// sur 00:00 si et seulement si les deux partagent le même décalage.
'periodStartedAt':
DateTime(2026, 9, 6).millisecondsSinceEpoch ~/ 1000 +
(divergent ? 3600 : 0),
'measuredSince': DateTime(2026, 9, 6).millisecondsSinceEpoch ~/ 1000,
'timeZone': divergent ? 'Europe/London' : 'Europe/Paris',
});
Widget harnessAvec(EnergyRatiosSnapshot? r,
{List<Map<String, dynamic>>? config}) =>
_harness(config ?? _charge(), ratios: r);
testWidgets('fuseaux divergents : la phrase dit l\'heure ET l\'horloge',
(tester) async {
await _poser(tester, harnessAvec(_instantane(divergent: true)));
expect(find.textContaining('12:00 heure de la box (Europe/London)'),
findsOneWidget);
expect(find.textContaining('pas celle de cet appareil'), findsOneWidget);
// Ce qu'on n'écrit surtout pas : un intitulé qui ressemble à un réglage.
expect(find.textContaining('Fuseau :'), findsNothing);
});
testWidgets('fuseaux identiques : RIEN — un texte permanent cesse d\'être lu',
(tester) async {
await _poser(tester, harnessAvec(_instantane(divergent: false)));
expect(find.textContaining('heure de la box'), findsNothing);
});
testWidgets('sans échéance saisie, rien à qualifier', (tester) async {
await _poser(tester,
harnessAvec(_instantane(divergent: true), config: _charge(echeance: '')));
expect(find.textContaining('heure de la box'), findsNothing);
});
testWidgets('box qui ne publie pas la date : on se tait', (tester) async {
await _poser(tester, harnessAvec(null));
expect(find.textContaining('heure de la box'), findsNothing);
});
});
}