etm-powersync-app/test/pv_forecast_test.dart
Patrick Schurig adb069f1d2 feat(énergie): prévision PV lue sur la box, marché spot masqué (+etm68)
Consigne app etm66/etm68, vérifiée contre le banc hems (1.15.2+etm68) le 2026-10-14.

- PvForecastCard remplace le placeholder « Météo & prévision » : lit
  NymeaEnergy.GetForecastSnapshot à l'ouverture puis toutes les 12 min
  (aucune notification n'existe). Pas des créneaux DÉDUIT, créneau absent
  = trou (jamais zéro), fault ⇒ pas de courbe avec son motif, libellé
  « plafond optimiste », corrections/plafond AC en mode installateur.
- MISSING_FORECAST_SITE : les candidats de GetForecastSite sont proposés,
  la désignation (SetForecastSite) se confirme ; acLimitW omis = inchangé.
- Production mesurée superposée depuis GetPowerBalanceLogs, signe inversé
  (pas .abs()), aucun cumul total*.
- Marché spot masqué dans Tarifs (SPOT_MARKET_NOT_IN_BUILD) : mode Spot
  Market, fournisseurs Tibber/Octopus/ENTSO-E et la carte de prix codés en dur.
- Bornes VE envoyées en {uuid} minuscules jusqu'à etm69 (SetChargingInfo,
  SetLoadConfig, Set/ClearLoadDerogation) via idBorneCanonique().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 07:40:20 +02:00

220 lines
8.4 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 prévision PV de `+etm68` — `NymeaEnergy.GetForecastSnapshot`.
///
/// La fixture est la réponse RÉELLE du banc `hems` (10.42.60.21), capturée le 2026-10-14.
/// Les cas de trou, de pas et de défaut sont dérivés d'elle ou écrits à la main, et chaque
/// test dit ce qu'il aurait attrapé s'il avait échoué.
library;
import 'dart:convert';
import 'dart:io';
import 'package:etm_powersync_app/models/pv_forecast.dart';
import 'package:etm_powersync_app/services/nymea_service.dart';
import 'package:etm_powersync_app/widgets/pv_forecast_card.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
Map<String, dynamic> _banc() => jsonDecode(
File('test/fixtures/forecast_snapshot_hems_etm68.json').readAsStringSync())
as Map<String, dynamic>;
Map<String, dynamic> _pt(String t, num wh) => {'targetAt': t, 'forecastWh': wh};
Widget _monte(PvForecastView v) => MaterialApp(
// Contrainte BORNÉE en largeur, hauteur infinie : c'est le montage réel (un
// ListView dans l'écran Énergie).
home: Scaffold(body: ListView(children: [v])),
);
PvForecastView _vue(PvForecastSnapshot? s,
{String? erreur,
PvForecastSite? site,
bool installateur = false,
List<ProductionMesuree> mesure = const []}) =>
PvForecastView(
chargement: false,
snapshot: s,
erreur: erreur,
site: site,
mesure: mesure,
maintenant: DateTime.utc(2026, 10, 14, 9),
installateur: installateur,
nomDuThing: (id) => id == '{8e0b7b38-3180-46be-ba41-08b08d630173}'
? 'Kutzenhausen'
: id,
);
void main() {
group('lecture de la réponse du banc', () {
final s = PvForecastSnapshot.fromJson(_banc());
test('48 créneaux, en UTC, triés', () {
expect(s.etat, PvForecastEtat.courbe);
expect(s.points, hasLength(48));
expect(s.points.first.debutUtc.isUtc, isTrue);
expect(s.points.first.debutUtc, DateTime.utc(2026, 10, 13, 22));
});
test('le pas est DÉDUIT : une heure sur le banc', () {
expect(s.pas, const Duration(hours: 1));
expect(s.creneauxManquants, 0);
expect(s.segments, hasLength(1));
});
test('métadonnées +etm68 lues', () {
expect(s.corrections, ['temperature']);
expect(s.conversion, 'physics');
expect(s.model, 'meteofrance_seamless');
expect(s.perField.single.corrections, ['temperature']);
expect(s.acLimitW, isNull); // pas de plafond configuré sur le banc
// Un INSTANT se rend chez le lecteur : l'attendu se dérive de toLocal(), il ne se
// recopie pas de la charge utile (sous TZ=UTC ce contrôle ne mord pas).
expect(s.contentChangedAt,
DateTime.utc(2026, 10, 14, 5, 15).toLocal());
expect(s.contentChangedAt!.isUtc, isFalse);
});
});
group('un créneau absent n\'est pas un zéro', () {
final s = PvForecastSnapshot.fromJson({
'capturedAt': '2026-10-14T05:00:00Z',
'forecastPoints': [
_pt('2026-10-14T08:00:00Z', 500),
_pt('2026-10-14T09:00:00Z', 900),
// 10:00 et 11:00 manquent
_pt('2026-10-14T12:00:00Z', 1100),
],
});
test('le pas reste une heure malgré le trou', () {
// Aurait attrapé un pas pris sur le premier écart venu, ou sur la moyenne.
expect(s.pas, const Duration(hours: 1));
expect(s.creneauxManquants, 2);
expect(s.segments.map((g) => g.length), [2, 1]);
});
testWidgets('la courbe est COUPÉE au trou, sans point à zéro', (t) async {
await t.pumpWidget(_monte(_vue(s)));
final chart = t.widget<LineChart>(find.byType(LineChart));
final spots = chart.data.lineBarsData.first.spots;
expect(spots.where((p) => p == FlSpot.nullSpot), hasLength(1));
// Aucun zéro n'a été inventé pour combler 10:00–12:00.
expect(spots.where((p) => p != FlSpot.nullSpot && p.y == 0), isEmpty);
expect(find.textContaining('2 créneaux sans prévision'), findsOneWidget);
});
});
test('un pas de 15 min se déduit aussi — rien n\'est codé en dur', () {
final s = PvForecastSnapshot.fromJson({
'forecastPoints': [
_pt('2026-10-14T08:00:00Z', 100),
_pt('2026-10-14T08:15:00Z', 120),
_pt('2026-10-14T08:30:00Z', 140),
],
});
expect(s.pas, const Duration(minutes: 15));
});
testWidgets('Wh par créneau → kW moyen : 250 Wh sur 15 min = 1 kW', (t) async {
final s = PvForecastSnapshot.fromJson({
'forecastPoints': [
_pt('2026-10-14T08:00:00Z', 250),
_pt('2026-10-14T08:15:00Z', 250),
],
});
await t.pumpWidget(_monte(_vue(s)));
final spots =
t.widget<LineChart>(find.byType(LineChart)).data.lineBarsData.first.spots;
// Aurait attrapé une heure codée en dur (0,25 kW) au lieu du pas déduit.
expect(spots.first.y, closeTo(1.0, 1e-9));
});
group('fault présent ⇒ pas de courbe', () {
testWidgets('MISSING_FORECAST_SITE propose les candidats, n\'adopte rien',
(t) async {
final s = PvForecastSnapshot.fromJson({
'capturedAt': '2026-10-14T05:00:00Z',
'fault': 'MISSING_FORECAST_SITE',
// Des points qui traîneraient ne doivent PAS être tracés.
'forecastPoints': [_pt('2026-10-14T08:00:00Z', 500)],
});
expect(s.etat, PvForecastEtat.siteManquant);
await t.pumpWidget(_monte(_vue(s,
site: const PvForecastSite(
candidats: ['{8e0b7b38-3180-46be-ba41-08b08d630173}']))));
expect(find.byType(LineChart), findsNothing);
expect(find.text('Kutzenhausen'), findsOneWidget);
expect(find.text('Désigner'), findsOneWidget);
});
testWidgets('FORECAST_PAYLOAD_REFUSED montre sa cause', (t) async {
final s = PvForecastSnapshot.fromJson({
'fault': 'FORECAST_PAYLOAD_REFUSED',
'faultParams': {'cause': 'unknown version 3'},
});
await t.pumpWidget(_monte(_vue(s)));
expect(find.byType(LineChart), findsNothing);
expect(find.textContaining('unknown version 3'), findsOneWidget);
});
testWidgets('un défaut inconnu est montré, jamais tu', (t) async {
final s = PvForecastSnapshot.fromJson({'fault': 'SOMETHING_NEW'});
expect(s.etat, PvForecastEtat.faultInconnu);
await t.pumpWidget(_monte(_vue(s)));
expect(find.text('SOMETHING_NEW'), findsOneWidget);
});
});
testWidgets('ni courbe ni fault : « aucune prévision publiée »', (t) async {
final s = PvForecastSnapshot.fromJson({'capturedAt': '2026-10-14T05:00:00Z'});
expect(s.etat, PvForecastEtat.rienPublie);
await t.pumpWidget(_monte(_vue(s)));
expect(find.text('Aucune prévision publiée'), findsOneWidget);
expect(find.byType(LineChart), findsNothing);
});
testWidgets('non lu ≠ rien de publié', (t) async {
await t.pumpWidget(_monte(_vue(null, erreur: 'TimeoutException')));
expect(find.text('Prévision non lue'), findsOneWidget);
expect(find.text('Aucune prévision publiée'), findsNothing);
});
testWidgets('libellé honnête, corrections en mode installateur seulement',
(t) async {
final s = PvForecastSnapshot.fromJson(_banc());
await t.pumpWidget(_monte(_vue(s)));
expect(find.textContaining('plafond optimiste'), findsWidgets);
expect(find.textContaining('température de module'), findsNothing);
await t.pumpWidget(_monte(_vue(s, installateur: true)));
expect(find.textContaining('température de module'), findsWidgets);
});
testWidgets('la production mesurée se superpose, signe déjà retourné',
(t) async {
final s = PvForecastSnapshot.fromJson(_banc());
await t.pumpWidget(_monte(_vue(s, mesure: [
(t: DateTime.utc(2026, 10, 14, 8), w: 600),
(t: DateTime.utc(2026, 10, 14, 8, 15), w: 700),
])));
final bars = t.widget<LineChart>(find.byType(LineChart)).data.lineBarsData;
expect(bars, hasLength(2));
expect(bars[1].spots.map((p) => p.y), [0.6, 0.7]);
});
group('bornes VE : `{uuid}` minuscules (jusqu\'à etm69)', () {
test('sans accolades, en majuscules → canonique', () {
expect(idBorneCanonique('4D1ADAFD-CAFC-4813-93B1-CA8933CE71AB'),
'{4d1adafd-cafc-4813-93b1-ca8933ce71ab}');
});
test('déjà canonique → inchangé', () {
const id = '{4d1adafd-cafc-4813-93b1-ca8933ce71ab}';
expect(idBorneCanonique(id), id);
});
test('un nom de charge n\'est pas réécrit', () {
expect(idBorneCanonique('chauffe-eau'), 'chauffe-eau');
});
});
}