package classify import "testing" func easyRule() Node { return Node{Match: MatchAll, Conditions: []Node{ {Metric: "avg_pace_sec_per_km", Op: OpBetween, Value: []any{330.0, 420.0}}, {Metric: "avg_hr_pct_max", Op: OpLte, Value: 0.75}, }} } func tempoRule() Node { return Node{Match: MatchAll, Conditions: []Node{ {Metric: "avg_pace_sec_per_km", Op: OpBetween, Value: []any{270.0, 330.0}}, {Metric: "avg_hr_pct_max", Op: OpGte, Value: 0.80}, }} } func testKinds() []RuleKind { return []RuleKind{ {WorkoutKindID: 1, Name: "Easy", Rule: easyRule()}, {WorkoutKindID: 2, Name: "Tempo", Rule: tempoRule()}, } } func TestClassify_CleanSingleMatch(t *testing.T) { ctx := MetricContext{"avg_pace_sec_per_km": 375, "avg_hr_pct_max": 0.65} result := Classify(ctx, testKinds(), DefaultMinConfidence) if result.Status != StatusAssigned { t.Fatalf("Status = %q, want %q", result.Status, StatusAssigned) } if result.WorkoutKindID == nil || *result.WorkoutKindID != 1 { t.Fatalf("WorkoutKindID = %v, want 1 (Easy)", result.WorkoutKindID) } if result.Confidence == nil || *result.Confidence < DefaultMinConfidence { t.Fatalf("Confidence = %v, want >= %v", result.Confidence, DefaultMinConfidence) } } func TestClassify_AmbiguousMultiMatch(t *testing.T) { // Overlapping rule zone: a kind covering the same pace range as both // Easy and Tempo, so a run in the overlap matches two kinds at once. overlap := RuleKind{WorkoutKindID: 3, Name: "Overlap", Rule: Node{ Match: MatchAll, Conditions: []Node{ {Metric: "avg_pace_sec_per_km", Op: OpBetween, Value: []any{300.0, 400.0}}, }, }} kinds := append(testKinds(), overlap) ctx := MetricContext{"avg_pace_sec_per_km": 375, "avg_hr_pct_max": 0.65} result := Classify(ctx, kinds, DefaultMinConfidence) if result.Status != StatusNeedsReview { t.Fatalf("Status = %q, want %q", result.Status, StatusNeedsReview) } if len(result.Candidates) < 2 { t.Fatalf("expected >= 2 candidates for ambiguous match, got %d: %+v", len(result.Candidates), result.Candidates) } if result.WorkoutKindID != nil { t.Fatalf("WorkoutKindID should be nil when needs_review, got %v", *result.WorkoutKindID) } } func TestClassify_NoMatch(t *testing.T) { // A very slow, low-HR run that fits neither Easy nor Tempo's pace range. ctx := MetricContext{"avg_pace_sec_per_km": 600, "avg_hr_pct_max": 0.55} result := Classify(ctx, testKinds(), DefaultMinConfidence) if result.Status != StatusNeedsReview { t.Fatalf("Status = %q, want %q", result.Status, StatusNeedsReview) } if len(result.Candidates) != 0 { t.Fatalf("expected 0 candidates for no match, got %d: %+v", len(result.Candidates), result.Candidates) } } func TestClassify_LowConfidenceSingleMatchNeedsReview(t *testing.T) { // Right at the edge of Easy's pace window and right at the HR ceiling -- // technically matches but only barely, so confidence should be low. ctx := MetricContext{"avg_pace_sec_per_km": 419, "avg_hr_pct_max": 0.75} result := Classify(ctx, testKinds(), 0.9) // deliberately strict threshold if result.Status != StatusNeedsReview { t.Fatalf("Status = %q, want %q (low confidence should force review)", result.Status, StatusNeedsReview) } if len(result.Candidates) != 1 { t.Fatalf("expected exactly 1 (low-confidence) candidate, got %d", len(result.Candidates)) } } func TestClassify_MissingMetricDoesNotMatch(t *testing.T) { ctx := MetricContext{"avg_pace_sec_per_km": 375} // avg_hr_pct_max absent result := Classify(ctx, testKinds(), DefaultMinConfidence) if result.Status != StatusNeedsReview { t.Fatalf("Status = %q, want %q", result.Status, StatusNeedsReview) } } func TestDetectIntervalPattern(t *testing.T) { cases := []struct { name string laps []LapInfo want bool }{ { name: "clear interval workout: alternating active/rest", laps: []LapInfo{ {IntensityType: "ACTIVE"}, {IntensityType: "REST"}, {IntensityType: "ACTIVE"}, {IntensityType: "REST"}, }, want: true, }, { name: "long run with a single hill lap should not look like intervals", laps: []LapInfo{ {IntensityType: "ACTIVE"}, {IntensityType: "ACTIVE"}, {IntensityType: "ACTIVE"}, }, want: false, }, { name: "warmup + single effort + cooldown is not repeated intervals", laps: []LapInfo{ {IntensityType: "WARMUP"}, {IntensityType: "ACTIVE"}, {IntensityType: "COOLDOWN"}, }, want: false, }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { got := DetectIntervalPattern(c.laps) if got != c.want { t.Errorf("DetectIntervalPattern() = %v, want %v", got, c.want) } }) } } func hr(v float64) *float64 { return &v } func TestHRDrift_RisingHeartRateDetected(t *testing.T) { var samples []SampleInfo for i := 0; i < 20; i++ { samples = append(samples, SampleInfo{ElapsedSeconds: float64(i * 10), HeartRate: hr(140 + float64(i))}) } drift, ok := HRDrift(samples) if !ok { t.Fatal("expected ok=true with enough samples") } if drift <= 0 { t.Errorf("drift = %v, want positive (rising HR)", drift) } } func TestHRDrift_TooFewSamplesIsNotOk(t *testing.T) { samples := []SampleInfo{{ElapsedSeconds: 0, HeartRate: hr(140)}, {ElapsedSeconds: 10, HeartRate: hr(142)}} _, ok := HRDrift(samples) if ok { t.Error("expected ok=false with too few samples") } } func TestHRRecovery_FallingHeartRateIsPositiveRecoveryRate(t *testing.T) { var samples []SampleInfo for i := 0; i < 20; i++ { samples = append(samples, SampleInfo{ElapsedSeconds: float64(i * 5), HeartRate: hr(170 - float64(i)*2)}) } recovery, ok := HRRecovery(samples) if !ok { t.Fatal("expected ok=true with enough samples") } if recovery <= 0 { t.Errorf("recovery = %v, want positive (HR dropping)", recovery) } } func TestHRRecovery_StillRisingIsNegative(t *testing.T) { var samples []SampleInfo for i := 0; i < 20; i++ { samples = append(samples, SampleInfo{ElapsedSeconds: float64(i * 5), HeartRate: hr(120 + float64(i))}) } recovery, ok := HRRecovery(samples) if !ok { t.Fatal("expected ok=true with enough samples") } if recovery >= 0 { t.Errorf("recovery = %v, want negative (HR still rising during recovery lap)", recovery) } }