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 }