Garmin run classification and progression tracker. Go backend (MCP client to mcp-garmin, SQLite store, deterministic rule engine, REST API) and React/TS frontend (Dashboard, Review Queue, Workout Kinds). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
191 lines
6.0 KiB
Go
191 lines
6.0 KiB
Go
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)
|
|
}
|
|
}
|