etm-powersync-app/test/journee_test.dart
Patrick Schurig 335e6a1290 feat(accueil): météo heure par heure sur la carte « Journée » (openmeteo etm16)
Consigne app §E, vérifiée sur le banc le 2026-10-15 :
- lecture de forecastHourlyWeather (v 1) sur le Thing SITE, parent des
  plans de perField (parentId désormais lu sur les Things) ; v inconnu =
  rien d'affiché, jamais forecastCurveTransport ;
- case i = valeur au DÉBUT du créneau t0 + i·step : posée sur l'heure qui
  commence là, comme la prévision PV ;
- icône (code WMO → icône, variante de nuit par isDay) et température une
  heure sur trois, alignées sur les colonnes du graphe ;
- une case null n'a ni icône ni température : WMO 0 = ciel dégagé, et un
  null lu comme 0 afficherait un soleil. Test à la lecture ajouté après
  qu'une mutation null → 0 n'eut fait échouer aucun test (la fixture du
  banc n'a pas de null).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 08:32:28 +02:00

253 lines
9.2 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: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);
});
});
}