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>
220 lines
8.4 KiB
Dart
220 lines
8.4 KiB
Dart
/// 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');
|
||
});
|
||
});
|
||
}
|