Files
dmc/app/test/services/wind_service_test.dart
T
Constantin LeueandClaude Sonnet 5 1b13c45cac Add wind analysis: header pill, fetch/validate menu, speed-plot overlay
Portiert das Wind-System des HTML-Demonstrators (Doku 3.8) vollstaendig:
Open-Meteo-Winddaten, interpoliert zwischen Druckflaechen- und festen
Nabenhoehen-Stuetzpunkten auf die tatsaechliche Zielhoehe.

Domain (lib/domain/wind/wind_math.dart, reines Dart): Hoehen-
Interpolation (linear + zirkulaer fuer Windrichtung), Peilung zwischen
zwei Punkten, Kopf-/Rueckenwind-Komponente pro Flugleg - 1:1 uebernommen
aus interpolateWindAtHeight()/bearingBetween()/headTailwindComponent()
des Demonstrators, per Unit-Tests abgesichert.

Service (lib/services/wind/wind_service.dart): zwei getrennte,
fokussierte Open-Meteo-Requests statt eines kombinierten (Doku 4.10 -
ein 35-Variablen-Request verursachte im Demonstrator Modellauswahl-
Fehler). Drei Verbraucher: Kopfleisten-Pille (120 m AGL ueber
Referenzort), gebuendelter Wegpunkt-Fetch (ein Request fuer alle
Koordinaten via Open-Meteos Mehrfach-Location-Syntax) und das
Validierungspanel (rohe Messwerte je Nabenhoehe/Druckflaeche). Per
MockClient getestet (kein echter Netzwerkzugriff in Tests).

UI:
- HeaderWindPill (TopModeBar, nur ausserhalb Fly-Modus): zeigt
  Windrichtung/-geschwindigkeit, Antippen schaltet perspektivisch die
  Wind-Marker der Wegpunkte um (Zustand vorbereitet, HTML-Demonstrator:
  showPerWaypointWind).
- WaypointListPanel: "Fetch wind"/"Validate"-Buttons im Panel-Header,
  neue WIND-Spalte in der Liste.
- WindValidatePanel: neue Vollbild-Route (gleiches Muster wie
  WaypointListPanel) mit Rohdaten je Hoehenstufe inkl. Boeen (Doku 4.9:
  Boeen nur hier, nicht in der Wegpunktanzeige) und Interpolationsergebnis.
- FullValueChart (Speed-Tab): Kopf-/Rueckenwind-Diamanten pro Leg samt
  gestrichelter Nulllinie sowie gestrichelte Bodengeschwindigkeits-
  Stufenlinie.

Bug gefunden und gefixt waehrend der Emulator-Verifikation: die
Y-Achsen-Autoskalierung klemmte weiterhin auf den gueltigen Drehrad-
Wertebereich (13-25 m/s), wodurch nahe Null liegende Windkomponenten
(bei schwachem Wind der Normalfall) ausserhalb des sichtbaren Bereichs
lagen und weder Diamanten noch Nulllinie zu sehen waren. Der HTML-
Demonstrator klemmt computeSpeedChartRange() bewusst nicht auf SPD_MIN/
SPD_MAX; das Klemmen in FullValueChart._range() jetzt entsprechend
entfernt.

Waypoint-Modell um windSpeedMs/windDirFromDeg/windElevationM erweitert
(nullable, reine Planungshilfe - der Encoder liest diese Felder nie,
Doku 3.2). currentMissionProvider bekommt replaceAll() fuer den
gebuendelten Wind-Fetch, der alle Wegpunkte gleichzeitig aktualisiert.

Verifiziert: flutter analyze (0 issues), flutter test (44/44, davon 14
neue Wind-Mathematik- und 6 neue WindService-Tests), manuell auf
Pixel_10a-Emulator mit echtem Open-Meteo-Netzwerkzugriff - Kopfleisten-
Pille zeigt Live-Wind, Fetch/Validate fuellen Liste bzw. oeffnen das
Validierungspanel mit echten Messwerten, Speed-Plot zeigt nach dem Fix
Diamanten/Nulllinie/Bodengeschwindigkeit korrekt skaliert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 13:32:15 +02:00

221 lines
7.2 KiB
Dart

import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:dmc_app/domain/waypoint/flat_waypoint_list.dart';
import 'package:dmc_app/services/wind/wind_service.dart';
bool _isPressureRequest(http.Request request) =>
(request.url.queryParameters['hourly'] ?? '').contains('hPa');
http.Response _json(Object body) =>
http.Response(jsonEncode(body), 200, headers: {
'content-type': 'application/json',
});
void main() {
group('WindService.fetchHeaderWind', () {
test('interpoliert auf 120 m AGL ueber dem Referenzort', () async {
final client = MockClient((request) async {
if (_isPressureRequest(request)) {
return http.Response('', 500);
}
return _json({
'elevation': 2.0,
'hourly': {
'time': ['2024-06-01T12:00'],
'wind_speed_10m': [3.0],
'wind_direction_10m': [190.0],
'wind_speed_80m': [5.0],
'wind_direction_80m': [200.0],
'wind_speed_120m': [6.0],
'wind_direction_120m': [205.0],
'wind_speed_180m': [8.0],
'wind_direction_180m': [210.0],
'wind_gusts_10m': [7.5],
},
});
});
final service = WindService(client: client);
final result = await service.fetchHeaderWind(52.0, 4.0);
// Ziel 2 + 120 = 122 m AMSL faellt exakt auf den 120-m-Stuetzpunkt.
expect(result, isNotNull);
expect(result!.speedMs, closeTo(6.0, 0.001));
expect(result.dirFromDeg, closeTo(205.0, 0.001));
});
test('liefert null, wenn beide Requests fehlschlagen', () async {
final client = MockClient((request) async => http.Response('', 500));
final service = WindService(client: client);
final result = await service.fetchHeaderWind(52.0, 4.0);
expect(result, isNull);
});
});
group('WindService.fetchWindForWaypoints', () {
test('interpoliert jeden Wegpunkt an seinem eigenen Ort/seiner eigenen Hoehe',
() async {
final client = MockClient((request) async {
if (_isPressureRequest(request)) {
return http.Response('', 500);
}
return _json([
{
'elevation': 2.0,
'hourly': {
'time': ['2024-06-01T12:00'],
'wind_speed_10m': [3.0],
'wind_direction_10m': [190.0],
'wind_speed_80m': [5.0],
'wind_direction_80m': [200.0],
'wind_speed_120m': [6.0],
'wind_direction_120m': [205.0],
'wind_speed_180m': [8.0],
'wind_direction_180m': [210.0],
'wind_gusts_10m': [7.5],
},
},
{
'elevation': 50.0,
'hourly': {
'time': ['2024-06-01T12:00'],
'wind_speed_10m': [1.0],
'wind_direction_10m': [100.0],
'wind_speed_80m': [9.0],
'wind_direction_80m': [250.0],
'wind_speed_120m': [10.0],
'wind_direction_120m': [260.0],
'wind_speed_180m': [11.0],
'wind_direction_180m': [270.0],
'wind_gusts_10m': [12.0],
},
},
]);
});
final service = WindService(client: client);
final waypoints = [
const Waypoint(
lat: 52.0,
lon: 4.0,
altitudeM: 10, // 2 + 10 = 12 m AMSL: exakt der 10-m-Stuetzpunkt
speedMs: 15,
catchRadiusM: 60,
),
const Waypoint(
lat: 52.1,
lon: 4.1,
altitudeM: 80, // 50 + 80 = 130 m AMSL: exakt der 80-m-Stuetzpunkt
speedMs: 15,
catchRadiusM: 60,
),
];
final result = await service.fetchWindForWaypoints(waypoints);
expect(result[0].windSpeedMs, closeTo(3.0, 0.001));
expect(result[0].windDirFromDeg, closeTo(190.0, 0.001));
expect(result[0].windElevationM, closeTo(2.0, 0.001));
expect(result[1].windSpeedMs, closeTo(9.0, 0.001));
expect(result[1].windDirFromDeg, closeTo(250.0, 0.001));
expect(result[1].windElevationM, closeTo(50.0, 0.001));
});
test('leere Wegpunktliste loest keinen Request aus', () async {
var called = false;
final client = MockClient((request) async {
called = true;
return http.Response('', 500);
});
final service = WindService(client: client);
final result = await service.fetchWindForWaypoints(const []);
expect(result, isEmpty);
expect(called, isFalse);
});
});
group('WindService.fetchValidation', () {
test('markiert unvollstaendige/unter Boden liegende Druckflaechen als excluded',
() async {
final client = MockClient((request) async {
if (_isPressureRequest(request)) {
return _json({
'elevation': 50.0,
'hourly': {
'time': ['2024-06-01T12:00'],
'wind_speed_1000hPa': [4.0],
'wind_direction_1000hPa': [300.0],
'geopotential_height_1000hPa': [5.0], // unter Bodenhoehe 50
'wind_speed_975hPa': [12.0],
'wind_direction_975hPa': [300.0],
'geopotential_height_975hPa': [800.0],
},
});
}
return _json({
'elevation': 50.0,
'hourly': {
'time': ['2024-06-01T12:00'],
'wind_speed_10m': [1.0],
'wind_direction_10m': [100.0],
'wind_speed_80m': [9.0],
'wind_direction_80m': [250.0],
'wind_speed_120m': [10.0],
'wind_direction_120m': [260.0],
'wind_speed_180m': [11.0],
'wind_direction_180m': [270.0],
'wind_gusts_10m': [12.5],
},
});
});
final service = WindService(client: client);
final result = await service.fetchValidation(52.0, 4.0);
expect(result.elevationM, closeTo(50.0, 0.001));
expect(result.fixedRequestFailed, isFalse);
expect(result.pressureRequestFailed, isFalse);
expect(result.gustMs, closeTo(12.5, 0.001));
final level1000 =
result.pressureReadings.firstWhere((r) => r.pressureHpa == 1000);
expect(level1000.excluded, isTrue,
reason: 'liegt unter der Bodenhoehe');
final level975 =
result.pressureReadings.firstWhere((r) => r.pressureHpa == 975);
expect(level975.excluded, isFalse);
final level950 =
result.pressureReadings.firstWhere((r) => r.pressureHpa == 950);
expect(level950.excluded, isTrue,
reason: 'keine Daten fuer diese Druckflaeche geliefert');
expect(result.interpolatedAt120, isNotNull);
});
test('meldet fehlgeschlagenen Request statt zu crashen', () async {
final client = MockClient((request) async {
if (_isPressureRequest(request)) return http.Response('', 500);
return http.Response('', 500);
});
final service = WindService(client: client);
final result = await service.fetchValidation(52.0, 4.0);
expect(result.fixedRequestFailed, isTrue);
expect(result.pressureRequestFailed, isTrue);
expect(result.interpolatedAt120, isNull);
});
});
}