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>
253 lines
9.2 KiB
Dart
253 lines
9.2 KiB
Dart
/// 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);
|
||
});
|
||
});
|
||
}
|