Files
paragliding/backend/internal/service/assessment_test.go
2026-01-03 14:16:16 -08:00

555 lines
14 KiB
Go

package service
import (
"testing"
"time"
"github.com/scottyah/paragliding/internal/model"
)
func TestAssessmentService_Evaluate(t *testing.T) {
service := NewAssessmentService()
thresholds := getDefaultThresholds()
t.Run("empty points", func(t *testing.T) {
result := service.Evaluate([]model.WeatherPoint{}, thresholds)
if result.Status != "BAD" {
t.Errorf("expected status BAD, got %s", result.Status)
}
if result.FlyableNow {
t.Error("expected FlyableNow to be false")
}
if result.BestWindow != nil {
t.Error("expected no best window")
}
if len(result.AllWindows) != 0 {
t.Errorf("expected 0 windows, got %d", len(result.AllWindows))
}
})
t.Run("good conditions", func(t *testing.T) {
now := time.Now()
points := []model.WeatherPoint{
createPoint(now, 10, 270), // Good
createPoint(now.Add(1*time.Hour), 10, 270),
createPoint(now.Add(2*time.Hour), 10, 270),
}
result := service.Evaluate(points, thresholds)
if result.Status != "GOOD" {
t.Errorf("expected status GOOD, got %s", result.Status)
}
if result.BestWindow == nil {
t.Fatal("expected a best window")
}
if result.BestWindow.Duration < 2*time.Hour {
t.Errorf("expected window duration >= 2h, got %v", result.BestWindow.Duration)
}
})
t.Run("no daylight hours", func(t *testing.T) {
// Create points at 2am
midnight := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
points := []model.WeatherPoint{
createPoint(midnight.Add(2*time.Hour), 10, 270),
createPoint(midnight.Add(3*time.Hour), 10, 270),
}
result := service.Evaluate(points, thresholds)
if result.Status != "BAD" {
t.Errorf("expected status BAD, got %s", result.Status)
}
if !contains(result.Reason, "daylight") {
t.Errorf("expected reason to mention daylight, got: %s", result.Reason)
}
})
}
func TestAssessmentService_FindFlyableWindows(t *testing.T) {
service := NewAssessmentService()
thresholds := getDefaultThresholds()
t.Run("single continuous window", func(t *testing.T) {
start := time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC)
points := []model.WeatherPoint{
createPoint(start, 10, 270),
createPoint(start.Add(1*time.Hour), 10, 270),
createPoint(start.Add(2*time.Hour), 10, 270),
}
windows := service.FindFlyableWindows(points, thresholds)
if len(windows) != 1 {
t.Fatalf("expected 1 window, got %d", len(windows))
}
if windows[0].Duration < 2*time.Hour {
t.Errorf("expected duration >= 2h, got %v", windows[0].Duration)
}
})
t.Run("multiple windows", func(t *testing.T) {
start := time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC)
points := []model.WeatherPoint{
// First window (2 hours)
createPoint(start, 10, 270),
createPoint(start.Add(1*time.Hour), 10, 270),
createPoint(start.Add(2*time.Hour), 10, 270),
// Break (bad wind)
createPoint(start.Add(3*time.Hour), 20, 270), // Too fast
createPoint(start.Add(4*time.Hour), 20, 270),
// Second window (3 hours)
createPoint(start.Add(5*time.Hour), 10, 270),
createPoint(start.Add(6*time.Hour), 10, 270),
createPoint(start.Add(7*time.Hour), 10, 270),
createPoint(start.Add(8*time.Hour), 10, 270),
}
windows := service.FindFlyableWindows(points, thresholds)
if len(windows) != 2 {
t.Fatalf("expected 2 windows, got %d", len(windows))
}
// First window should be ~2 hours
if windows[0].Duration < 2*time.Hour {
t.Errorf("first window duration should be >= 2h, got %v", windows[0].Duration)
}
// Second window should be ~3 hours
if windows[1].Duration < 3*time.Hour {
t.Errorf("second window duration should be >= 3h, got %v", windows[1].Duration)
}
})
t.Run("window less than 1 hour excluded", func(t *testing.T) {
start := time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC)
points := []model.WeatherPoint{
createPoint(start, 10, 270),
createPoint(start.Add(30*time.Minute), 10, 270), // Only 30 minutes
createPoint(start.Add(1*time.Hour), 20, 270), // Bad wind
}
windows := service.FindFlyableWindows(points, thresholds)
if len(windows) != 0 {
t.Errorf("expected 0 windows (< 1h), got %d", len(windows))
}
})
t.Run("exactly 1 hour window", func(t *testing.T) {
start := time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC)
points := []model.WeatherPoint{
createPoint(start, 10, 270),
createPoint(start.Add(1*time.Hour), 10, 270),
createPoint(start.Add(2*time.Hour), 20, 270), // Bad wind
}
windows := service.FindFlyableWindows(points, thresholds)
if len(windows) != 1 {
t.Fatalf("expected 1 window, got %d", len(windows))
}
if windows[0].Duration < time.Hour {
t.Errorf("window should be at least 1 hour, got %v", windows[0].Duration)
}
})
t.Run("no flyable conditions", func(t *testing.T) {
start := time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC)
points := []model.WeatherPoint{
createPoint(start, 20, 270), // Too fast
createPoint(start.Add(1*time.Hour), 5, 270), // Too slow
createPoint(start.Add(2*time.Hour), 10, 180), // Wrong direction
}
windows := service.FindFlyableWindows(points, thresholds)
if len(windows) != 0 {
t.Errorf("expected 0 windows, got %d", len(windows))
}
})
}
func TestAssessmentService_isPointFlyable(t *testing.T) {
service := NewAssessmentService()
thresholds := getDefaultThresholds()
tests := []struct {
name string
point model.WeatherPoint
expected bool
}{
{
name: "perfect conditions",
point: createPoint(time.Now(), 10, 270),
expected: true,
},
{
name: "wind too slow",
point: createPoint(time.Now(), 5, 270),
expected: false,
},
{
name: "wind too fast",
point: createPoint(time.Now(), 20, 270),
expected: false,
},
{
name: "wrong direction",
point: createPoint(time.Now(), 10, 180), // South, not west
expected: false,
},
{
name: "edge of speed range - min",
point: createPoint(time.Now(), 7, 270),
expected: true,
},
{
name: "edge of speed range - max",
point: createPoint(time.Now(), 14, 270),
expected: true,
},
{
name: "edge of direction range - low",
point: createPoint(time.Now(), 10, 255), // 270 - 15
expected: true,
},
{
name: "edge of direction range - high",
point: createPoint(time.Now(), 10, 285), // 270 + 15
expected: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := service.isPointFlyable(tt.point, thresholds)
if result != tt.expected {
t.Errorf("expected %v, got %v", tt.expected, result)
}
})
}
}
func TestAssessmentService_DirectionWraparound(t *testing.T) {
service := NewAssessmentService()
tests := []struct {
name string
direction int
center int
rangeVal int
shouldMatch bool
}{
{
name: "wraparound - north center, matches 350",
direction: 350,
center: 0,
rangeVal: 15,
shouldMatch: true,
},
{
name: "wraparound - north center, matches 10",
direction: 10,
center: 0,
rangeVal: 15,
shouldMatch: true,
},
{
name: "wraparound - 350 center, matches 0",
direction: 0,
center: 350,
rangeVal: 20,
shouldMatch: true,
},
{
name: "wraparound - 350 center, matches 5",
direction: 5,
center: 350,
rangeVal: 20,
shouldMatch: true,
},
{
name: "wraparound - 350 center, matches 340",
direction: 340,
center: 350,
rangeVal: 20,
shouldMatch: true,
},
{
name: "wraparound - 350 center, rejects 30",
direction: 30,
center: 350,
rangeVal: 20,
shouldMatch: false,
},
{
name: "no wraparound - normal case",
direction: 180,
center: 180,
rangeVal: 15,
shouldMatch: true,
},
{
name: "no wraparound - within range",
direction: 190,
center: 180,
rangeVal: 15,
shouldMatch: true,
},
{
name: "no wraparound - out of range",
direction: 200,
center: 180,
rangeVal: 15,
shouldMatch: false,
},
{
name: "negative direction normalized",
direction: -10,
center: 350,
rangeVal: 20,
shouldMatch: true,
},
{
name: "direction over 360 normalized",
direction: 370,
center: 10,
rangeVal: 15,
shouldMatch: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := service.isDirectionInRange(tt.direction, tt.center, tt.rangeVal)
if result != tt.shouldMatch {
t.Errorf("expected %v, got %v for direction=%d, center=%d, range=%d",
tt.shouldMatch, result, tt.direction, tt.center, tt.rangeVal)
}
})
}
}
func TestAssessmentService_DaylightFiltering(t *testing.T) {
service := NewAssessmentService()
baseDate := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
tests := []struct {
name string
hour int
included bool
}{
{"7am - before daylight", 7, false},
{"8am - start of daylight", 8, true},
{"12pm - middle of day", 12, true},
{"6pm - evening", 18, true},
{"9pm - late evening", 21, true},
{"10pm - end of daylight", 22, false},
{"11pm - night", 23, false},
{"2am - night", 2, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
points := []model.WeatherPoint{
createPoint(baseDate.Add(time.Duration(tt.hour)*time.Hour), 10, 270),
}
filtered := service.filterDaylightHours(points)
if tt.included && len(filtered) != 1 {
t.Errorf("expected point at %d:00 to be included", tt.hour)
}
if !tt.included && len(filtered) != 0 {
t.Errorf("expected point at %d:00 to be excluded", tt.hour)
}
})
}
}
func TestAssessmentService_BestWindowSelection(t *testing.T) {
service := NewAssessmentService()
thresholds := getDefaultThresholds()
start := time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC)
points := []model.WeatherPoint{
// First window (2 hours)
createPoint(start, 10, 270),
createPoint(start.Add(1*time.Hour), 10, 270),
createPoint(start.Add(2*time.Hour), 10, 270),
// Break
createPoint(start.Add(3*time.Hour), 20, 270),
// Second window (4 hours) - this should be the best
createPoint(start.Add(4*time.Hour), 10, 270),
createPoint(start.Add(5*time.Hour), 10, 270),
createPoint(start.Add(6*time.Hour), 10, 270),
createPoint(start.Add(7*time.Hour), 10, 270),
createPoint(start.Add(8*time.Hour), 10, 270),
// Break
createPoint(start.Add(9*time.Hour), 5, 270),
// Third window (1 hour)
createPoint(start.Add(10*time.Hour), 10, 270),
createPoint(start.Add(11*time.Hour), 10, 270),
}
result := service.Evaluate(points, thresholds)
if result.BestWindow == nil {
t.Fatal("expected a best window")
}
// Best window should be the 4-hour window
if result.BestWindow.Duration < 4*time.Hour {
t.Errorf("expected best window to be >= 4 hours, got %v", result.BestWindow.Duration)
}
// Should have found 3 windows total
if len(result.AllWindows) != 3 {
t.Errorf("expected 3 windows, got %d", len(result.AllWindows))
}
}
func TestAssessmentService_EdgeCases(t *testing.T) {
service := NewAssessmentService()
thresholds := getDefaultThresholds()
t.Run("single point - flyable", func(t *testing.T) {
points := []model.WeatherPoint{
createPoint(time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC), 10, 270),
}
result := service.Evaluate(points, thresholds)
// Single point can't form a 1-hour window
if result.BestWindow != nil {
t.Error("single point should not create a window")
}
})
t.Run("all points same time", func(t *testing.T) {
sameTime := time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC)
points := []model.WeatherPoint{
createPoint(sameTime, 10, 270),
createPoint(sameTime, 10, 270),
createPoint(sameTime, 10, 270),
}
windows := service.FindFlyableWindows(points, thresholds)
// Should handle gracefully
if len(windows) > 1 {
t.Errorf("expected at most 1 window for same-time points, got %d", len(windows))
}
})
t.Run("custom thresholds", func(t *testing.T) {
customThresholds := model.Thresholds{
SpeedMin: 5,
SpeedMax: 10,
DirCenter: 180,
DirRange: 30,
}
start := time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC)
points := []model.WeatherPoint{
createPoint(start, 7, 170), // Within custom range
createPoint(start.Add(1*time.Hour), 7, 170),
createPoint(start.Add(2*time.Hour), 7, 170),
}
result := service.Evaluate(points, customThresholds)
if result.Status != "GOOD" {
t.Errorf("expected GOOD status with custom thresholds, got %s", result.Status)
}
})
}
func TestAssessmentService_NormalizeDegrees(t *testing.T) {
service := NewAssessmentService()
tests := []struct {
input int
expected int
}{
{0, 0},
{180, 180},
{360, 0},
{361, 1},
{720, 0},
{-10, 350},
{-90, 270},
{-360, 0},
}
for _, tt := range tests {
result := service.normalizeDegrees(tt.input)
if result != tt.expected {
t.Errorf("normalizeDegrees(%d) = %d, expected %d", tt.input, result, tt.expected)
}
}
}
func TestAssessmentService_AngleDifference(t *testing.T) {
service := NewAssessmentService()
tests := []struct {
angle1 int
angle2 int
expected float64
}{
{0, 0, 0},
{0, 180, 180},
{0, 10, 10},
{10, 0, 10},
{350, 10, 20}, // Wraparound
{10, 350, 20}, // Wraparound
{270, 90, 180}, // Opposite
{359, 1, 2}, // Close wraparound
}
for _, tt := range tests {
result := service.angleDifference(tt.angle1, tt.angle2)
if result != tt.expected {
t.Errorf("angleDifference(%d, %d) = %.1f, expected %.1f",
tt.angle1, tt.angle2, result, tt.expected)
}
}
}
// Helper functions
func getDefaultThresholds() model.Thresholds {
return model.Thresholds{
SpeedMin: 7,
SpeedMax: 14,
DirCenter: 270,
DirRange: 15,
}
}
func createPoint(t time.Time, speed float64, direction int) model.WeatherPoint {
return model.WeatherPoint{
Time: t,
WindSpeedMPH: speed,
WindDirection: direction,
WindGustMPH: speed + 2, // Arbitrary gust value
}
}
func contains(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}