« 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
196 lines
8.7 KiB
Dart
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);
|
|
});
|
|
});
|
|
}
|