Initial commit: smartrun MVP
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>
This commit is contained in:
190
backend/internal/classify/engine_test.go
Normal file
190
backend/internal/classify/engine_test.go
Normal file
@@ -0,0 +1,190 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user