etm-powersync-app/test/journee_test.dart
Patrick Schurig b65b5474fb feat(accueil): choisir le jour de la carte « Journée »
Demande de Patrick du 2026-10-16 (inspiré du sélecteur Victron, réduit) :
‹ jour › avec calendrier, raccourcis Hier / Aujourd'hui / Demain, et un
lien « Plusieurs jours » vers l'écran Énergie — pas de plage de plusieurs
jours dans une carte à colonnes horaires.

- Jour passé : mesures du jour et prévision ENREGISTRÉE telle qu'elle
  était connue à son début (GetPvForecast + knownAt) — sinon la table
  rendrait la dernière révision, faite après coup.
- Demain : prévision seule ; au-delà, flèche désactivée (horizon).
- Météo de maintenant sur aujourd'hui seulement ; rangée horaire si la
  série couvre le jour (aujourd'hui, demain).
- Une réponse tardive d'un jour quitté est jetée.
- Correctif trouvé par le test de navigation : la carte ne relisait ses
  mesures qu'après une prévision lue avec succès ; sur une box sans
  prévision, elle restait sur le sablier. Elle suit désormais le compteur
  de lectures du provider.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 06:12:01 +02:00

309 lines
12 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/// La carte « Journée » de l'accueil — variante E, retenue par Patrick le 2026-10-15.
///
/// Les attendus d'heure se dérivent de `DateTime(...)` local, jamais d'une heure recopiée :
/// sous TZ=UTC certains contrôles ne mordent pas tous, d'où la suite lancée aussi en
/// Europe/Paris.
library;
import 'dart:convert';
import 'dart:io';
import 'package:etm_powersync_app/features/dashboard/widgets/journee_card.dart';
import 'package:etm_powersync_app/models/consumer_series.dart';
import 'package:etm_powersync_app/models/energy_data.dart';
import 'package:etm_powersync_app/models/journee.dart';
import 'package:etm_powersync_app/models/pv_forecast.dart';
import 'package:etm_powersync_app/providers/load_config_provider.dart';
import 'package:etm_powersync_app/providers/pv_forecast_provider.dart';
import 'package:etm_powersync_app/services/nymea_service.dart';
import 'package:provider/provider.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
PowerBalanceEntry _bilan(DateTime t,
{double prod = 0, double cons = 0, double acq = 0}) =>
PowerBalanceEntry(
totalsPresent: false,
timestamp: t,
productionW: prod,
consumptionW: cons,
acquisitionW: acq,
storageW: 0,
totalProductionKwh: 0,
totalConsumptionKwh: 0,
totalReturnKwh: 0,
totalAcquisitionKwh: 0,
);
void main() {
final jour = DateTime(2026, 10, 14);
final dix = DateTime(2026, 10, 14, 10); // 10:00 locale
final onze = DateTime(2026, 10, 14, 11);
final snap = PvForecastSnapshot.fromJson({
'forecastPoints': [
for (var i = -1; i < 3; i++)
{
'targetAt': dix.toUtc().add(Duration(hours: i)).toIso8601String(),
'forecastWh': 500.0 + i
}
],
});
// La V2C a tiré 3 000 W en moyenne de 10:00 à 11:00 : échantillon horodaté 11:00.
final conso = ConsumerHistory(series: [
ConsumerSeries(
thingId: 'v2c',
label: 'V2C Trydan',
domaine: 'ev',
points: [ConsumerPoint(onze, 3000)]),
], sansHistorique: const []);
final heures = construireJournee(
jourLocal: jour,
snapshot: snap,
// Production nymea NÉGATIVE ; acquisition négative = injection.
bilanHoraire: [_bilan(onze, prod: -900, cons: 3600, acq: 3000)],
consoHoraire: conso,
);
HeureJournee a(int h) => heures.firstWhere((x) => x.debut.hour == h);
test('un échantillon horodaté T appartient à l\'heure qui COMMENCE à T − 1 h', () {
// Aurait attrapé une mesure posée sur l'heure de son horodatage : décalée d'une heure
// par rapport à la prévision et aux icônes.
expect(a(10).consWh, 3600);
expect(a(11).consWh, isNull);
expect(a(10).prevuWh, 500);
});
test('production : signe inversé, et décomposée en autoconsommé / injecté', () {
expect(a(10).prodWh, 900);
expect(a(10).exportWh, 0);
expect(a(10).autoWh, 900);
final inj = construireJournee(
jourLocal: jour,
snapshot: null,
bilanHoraire: [_bilan(onze, prod: -900, cons: 400, acq: -500)],
consoHoraire: ConsumerHistory.vide,
).firstWhere((x) => x.debut.hour == 10);
expect(inj.exportWh, 500);
expect(inj.autoWh, 400);
});
test('la Maison est ce que les compteurs déclarés n\'expliquent pas', () {
expect(a(10).parConsommateur['v2c'], 3000);
expect(a(10).resteWh, 600);
});
test('une heure non mesurée et non prévue reste inconnue — pas zéro', () {
expect(a(3).consWh, isNull);
expect(a(3).prevuWh, isNull);
});
test('une heure à moitié prévue n\'est pas sommée à moitié', () {
final quarts = PvForecastSnapshot.fromJson({
'forecastPoints': [
for (final m in [0, 15, 30]) // 10:45 manque
{
'targetAt': dix.toUtc().add(Duration(minutes: m)).toIso8601String(),
'forecastWh': 100
}
],
});
final h = construireJournee(
jourLocal: jour,
snapshot: quarts,
bilanHoraire: const [],
consoHoraire: ConsumerHistory.vide)
.firstWhere((x) => x.debut.hour == 10);
expect(h.prevuWh, isNull);
});
test('le jour du passage à l\'heure d\'hiver compte ses heures, pas 24', () {
final n = construireJournee(
jourLocal: DateTime(2026, 10, 25),
snapshot: null,
bilanHoraire: const [],
consoHoraire: ConsumerHistory.vide)
.length;
expect(n,
DateTime(2026, 10, 26).toUtc().difference(DateTime(2026, 10, 25).toUtc()).inHours);
});
group('météo de maintenant', () {
test('états jamais écrits (updateTime 0) : pas de soleil à 0 °C', () {
// Les défauts du plugin : weatherCondition « clear-day », temperature 0.
expect(
MeteoActuelle.depuisEtats(
{'updateTime': 0, 'weatherCondition': 'clear-day', 'temperature': 0}),
isNull);
});
test('états écrits : lus, et nommés en français', () {
final m = MeteoActuelle.depuisEtats({
'updateTime': 1792039500,
'weatherCondition': 'overcast',
'temperature': 4.6,
'temperatureMin': 4.1,
'temperatureMax': 19.8,
'sunriseTime': 1792043332,
'sunsetTime': 1792082350,
})!;
expect(m.libelle, 'Couvert');
expect(m.temperature, 4.6);
expect(m.lever,
DateTime.fromMillisecondsSinceEpoch(1792043332 * 1000, isUtc: true).toLocal());
});
});
testWidgets('deux colonnes par heure : consommation empilée, solaire à côté',
(t) async {
await t.pumpWidget(MaterialApp(
home: Scaffold(
body: ListView(children: [
JourneeView(heures: heures, series: conso.series),
]))));
final groupes = t.widget<BarChart>(find.byType(BarChart)).data.barGroups;
expect(groupes, hasLength(heures.length));
final i10 = heures.indexWhere((h) => h.debut.hour == 10);
final g = groupes[i10].barRods;
expect(g, hasLength(2));
// Colonne consommation : Maison 0,6 puis V2C 3,0.
expect(g[0].rodStackItems.map((s) => (s.fromY, s.toY)),
[(0.0, 0.6), (0.6, 3.6)]);
// Colonne solaire : 0,9 autoconsommé, prévision 0,5 en fond.
expect(g[1].toY, closeTo(0.9, 1e-9));
expect(g[1].backDrawRodData.toY, closeTo(0.5, 1e-9));
// Heure à venir prévue : la prévision elle-même, en contour.
final i11 = heures.indexWhere((h) => h.debut.hour == 11);
expect(groupes[i11].barRods[1].toY, closeTo(0.501, 1e-9));
expect(groupes[i11].barRods[1].borderSide.width, 1);
expect(find.text('V2C Trydan'), findsOneWidget);
expect(find.text('Maison'), findsOneWidget);
});
group('météo heure par heure (openmeteo etm16)', () {
final banc = jsonDecode(
File('test/fixtures/forecast_hourly_weather_hems_etm16.json').readAsStringSync())
as Map<String, dynamic>;
test('la réponse du banc : 48 cases, posées sur le DÉBUT de chaque heure', () {
final m = meteoHoraireDepuis(banc)!;
expect(m, hasLength(48));
final t0 = DateTime.fromMillisecondsSinceEpoch((banc['t0'] as int) * 1000, isUtc: true);
expect(m[t0]!.code, (banc['weatherCode'] as List).first);
expect(m[t0.add(const Duration(hours: 1))]!.tempC,
((banc['tempC'] as List)[1] as num).toDouble());
});
test('à la LECTURE, une case null reste null — pas un 0, donc pas un soleil', () {
// La fixture du banc n'a aucune null : sans ce test, une lecture qui écrirait 0 à la
// place passerait inaperçue (mesuré : la mutation ne faisait échouer aucun test).
final m = meteoHoraireDepuis({
...banc,
'weatherCode': [0, null],
'isDay': [1, null],
'tempC': [12.0, null],
})!;
final t0 = DateTime.fromMillisecondsSinceEpoch((banc['t0'] as int) * 1000, isUtc: true);
expect(m[t0]!.code, 0);
final apres = m[t0.add(const Duration(hours: 1))]!;
expect(apres.code, isNull);
expect(apres.jour, isNull);
expect(apres.tempC, isNull);
});
test('forme de version inconnue : rien n\'est lu', () {
expect(meteoHoraireDepuis({...banc, 'v': 2}), isNull);
});
testWidgets('un vrai 0 affiche un soleil, un null n\'affiche RIEN', (t) async {
// Le piège nommé par la consigne : WMO 0 = ciel dégagé.
final dix = DateTime(2026, 10, 14, 9); // heure multiple de 3 : affichée
final douze = DateTime(2026, 10, 14, 12);
final hs = construireJournee(
jourLocal: jour,
snapshot: null,
bilanHoraire: const [],
consoHoraire: ConsumerHistory.vide);
await t.pumpWidget(MaterialApp(
home: Scaffold(
body: ListView(children: [
JourneeView(heures: hs, meteoHoraire: {
dix.toUtc(): (code: 0, jour: true, tempC: 12.0),
douze.toUtc(): (code: null, jour: true, tempC: null),
}),
]))));
expect(find.byIcon(Icons.wb_sunny_rounded), findsOneWidget);
expect(find.text('12°'), findsOneWidget);
// Aucune autre icône météo dans la rangée : la case null n'en a pas reçu.
for (final i in [
Icons.nightlight_round,
Icons.cloud_rounded,
Icons.wb_cloudy_rounded,
]) {
expect(find.byIcon(i), findsNothing);
}
});
test('la nuit a sa variante, un code inconnu n\'a pas d\'icône', () {
expect(JourneeView.iconeWmo(0, false), Icons.nightlight_round);
expect(JourneeView.iconeWmo(95, true), Icons.thunderstorm_rounded);
expect(JourneeView.iconeWmo(42, true), isNull);
});
});
group('navigation entre les jours', () {
Future<PvForecastProvider> monte(WidgetTester t) async {
final service = NymeaService(); // non connecté : chaque lecture rend vide
final pv = PvForecastProvider()..attach(service);
await t.pumpWidget(MultiProvider(
providers: [
ChangeNotifierProvider.value(value: service),
ChangeNotifierProvider.value(value: pv),
ChangeNotifierProvider(create: (_) => LoadConfigProvider()),
],
child: MaterialApp(
home: Scaffold(body: ListView(children: const [JourneeCard()]))),
));
await t.pumpAndSettle();
return pv;
}
Future<void> demonte(WidgetTester t, PvForecastProvider pv) async {
await t.pumpWidget(const SizedBox());
pv.dispose(); // arrête le minuteur de relecture
}
testWidgets('ouvre sur aujourd\'hui, ‹ va à hier, la butée est demain', (t) async {
final pv = await monte(t);
expect(find.textContaining('Aujourd\'hui ·'), findsOneWidget);
await t.tap(find.byTooltip('Jour précédent'));
await t.pumpAndSettle();
expect(find.textContaining('Hier ·'), findsOneWidget);
await t.tap(find.text('Demain'));
await t.pumpAndSettle();
expect(find.textContaining('Demain ·'), findsOneWidget);
// Au-delà de demain, la prévision n'existe pas : la flèche est désactivée.
final suivant = t.widget<IconButton>(find.ancestor(
of: find.byIcon(Icons.chevron_right_rounded),
matching: find.byType(IconButton)));
expect(suivant.onPressed, isNull);
await demonte(t, pv);
});
testWidgets('la météo de maintenant n\'apparaît que sur aujourd\'hui', (t) async {
final pv = await monte(t);
expect(find.text('Météo non publiée par la box'), findsOneWidget);
await t.tap(find.text('Hier'));
await t.pumpAndSettle();
expect(find.text('Météo non publiée par la box'), findsNothing);
await demonte(t, pv);
});
});
}