2026-07-17 18:33:06 +02:00
|
|
|
package api
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"bytes"
|
|
|
|
|
"context"
|
|
|
|
|
"encoding/json"
|
2026-07-19 16:31:24 +02:00
|
|
|
"fmt"
|
2026-07-17 18:33:06 +02:00
|
|
|
"net/http"
|
|
|
|
|
"net/http/httptest"
|
2026-07-19 16:31:24 +02:00
|
|
|
"net/url"
|
2026-07-17 18:33:06 +02:00
|
|
|
"path/filepath"
|
|
|
|
|
"strconv"
|
|
|
|
|
"testing"
|
|
|
|
|
"time"
|
|
|
|
|
|
2026-07-24 21:08:07 +02:00
|
|
|
"geniusrun/backend/internal/garmin/mock"
|
|
|
|
|
"geniusrun/backend/internal/store"
|
|
|
|
|
appsync "geniusrun/backend/internal/sync"
|
2026-07-17 18:33:06 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
func newCtx() context.Context { return context.Background() }
|
|
|
|
|
|
|
|
|
|
func newTestServer(t *testing.T) (*Server, *store.DB) {
|
|
|
|
|
t.Helper()
|
2026-07-24 21:08:07 +02:00
|
|
|
db, err := store.Open(filepath.Join(t.TempDir(), "geniusrun_test.db"))
|
2026-07-17 18:33:06 +02:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("store.Open: %v", err)
|
|
|
|
|
}
|
|
|
|
|
t.Cleanup(func() { db.Close() })
|
|
|
|
|
|
|
|
|
|
m := &mock.Client{}
|
|
|
|
|
svc := appsync.NewService(m, db, appsync.Config{}, func() time.Time { return time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC) })
|
|
|
|
|
return NewServer(db, m, svc), db
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func doJSON(t *testing.T, handler http.Handler, method, path string, body any) *httptest.ResponseRecorder {
|
|
|
|
|
t.Helper()
|
|
|
|
|
var reader *bytes.Reader
|
|
|
|
|
if body != nil {
|
|
|
|
|
b, err := json.Marshal(body)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("marshal request body: %v", err)
|
|
|
|
|
}
|
|
|
|
|
reader = bytes.NewReader(b)
|
|
|
|
|
} else {
|
|
|
|
|
reader = bytes.NewReader(nil)
|
|
|
|
|
}
|
|
|
|
|
req := httptest.NewRequest(method, path, reader)
|
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
|
handler.ServeHTTP(rec, req)
|
|
|
|
|
return rec
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestHealth(t *testing.T) {
|
|
|
|
|
s, _ := newTestServer(t)
|
|
|
|
|
rec := doJSON(t, s.Router(), http.MethodGet, "/api/health", nil)
|
|
|
|
|
if rec.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("status = %d, want 200", rec.Code)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-19 10:52:09 +02:00
|
|
|
func TestWorkoutKindList_ReturnsEightSeededTypesWithPaceFields(t *testing.T) {
|
2026-07-17 18:33:06 +02:00
|
|
|
s, _ := newTestServer(t)
|
2026-07-17 19:09:55 +02:00
|
|
|
rec := doJSON(t, s.Router(), http.MethodGet, "/api/workout-kinds/", nil)
|
|
|
|
|
if rec.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("status = %d", rec.Code)
|
2026-07-17 18:33:06 +02:00
|
|
|
}
|
2026-07-17 19:09:55 +02:00
|
|
|
var kinds []workoutKindResponse
|
|
|
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &kinds); err != nil {
|
|
|
|
|
t.Fatalf("unmarshal: %v", err)
|
2026-07-17 18:33:06 +02:00
|
|
|
}
|
2026-07-19 10:52:09 +02:00
|
|
|
if len(kinds) != 8 {
|
|
|
|
|
t.Fatalf("expected 8 seeded kinds, got %d", len(kinds))
|
2026-07-17 18:33:06 +02:00
|
|
|
}
|
2026-07-17 19:09:55 +02:00
|
|
|
for _, k := range kinds {
|
Dedup Garmin data storage, configurable chart colors, taxonomy fixes, sync/progression fixes
- Remove duplicated Garmin fields from storage; decode display-only fields
(activity name/type, lap duration/HR, structured workout raw JSON) from
RawJSON at API-response time instead of storing redundant columns
- Add a fully configurable chart color system (pace/HR main-line colors, 4
effort-kind colors, tint/darken/brighten intensity knobs) under Profile >
Chart colors
- Rename training types and fix their display order (Easy, Long, 60'/30'
Threshold, Tempo, Intervals, MAS Test, Race) everywhere they're listed
- Add an Efficiency Factor progression metric; fix Progression chart axes to
use tight non-zero-based domains, m:ss/km pace formatting, and rounded
ticks instead of raw floating-point labels
- Expose the raw get_workout_by_id() payload in the raw-data viewer
alongside activity/lap/detail JSON; enlarge the modal and shrink array
indentation for readability
- Fix "last sync" reporting a meaningless activity count: record one
combined sync run per manual "Sync now" and count genuinely new
activities instead of re-listing whatever Garmin returned for the queried
window
- Let a Review Queue activity be manually cleared back to Unclassified, and
make "Reset all" available even while disconnected from Garmin
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 06:33:47 +02:00
|
|
|
if k.PaceMinSecPerKm != nil || k.PaceMaxSecPerKm != nil || k.HRMinPctHRR != nil || k.HRMaxPctHRR != nil {
|
2026-07-17 19:09:55 +02:00
|
|
|
t.Errorf("expected freshly-seeded kind %q to have nil pace fields, got %+v", k.Name, k)
|
|
|
|
|
}
|
2026-07-17 18:33:06 +02:00
|
|
|
}
|
2026-07-17 19:09:55 +02:00
|
|
|
}
|
2026-07-17 18:33:06 +02:00
|
|
|
|
2026-07-17 19:09:55 +02:00
|
|
|
func TestWorkoutKindUpdate_SetsRuleAndPace(t *testing.T) {
|
|
|
|
|
s, _ := newTestServer(t)
|
|
|
|
|
router := s.Router()
|
|
|
|
|
|
|
|
|
|
rec := doJSON(t, router, http.MethodGet, "/api/workout-kinds/", nil)
|
|
|
|
|
var kinds []workoutKindResponse
|
2026-07-17 18:33:06 +02:00
|
|
|
json.Unmarshal(rec.Body.Bytes(), &kinds)
|
2026-07-17 19:09:55 +02:00
|
|
|
target := kinds[0]
|
|
|
|
|
|
Dedup Garmin data storage, configurable chart colors, taxonomy fixes, sync/progression fixes
- Remove duplicated Garmin fields from storage; decode display-only fields
(activity name/type, lap duration/HR, structured workout raw JSON) from
RawJSON at API-response time instead of storing redundant columns
- Add a fully configurable chart color system (pace/HR main-line colors, 4
effort-kind colors, tint/darken/brighten intensity knobs) under Profile >
Chart colors
- Rename training types and fix their display order (Easy, Long, 60'/30'
Threshold, Tempo, Intervals, MAS Test, Race) everywhere they're listed
- Add an Efficiency Factor progression metric; fix Progression chart axes to
use tight non-zero-based domains, m:ss/km pace formatting, and rounded
ticks instead of raw floating-point labels
- Expose the raw get_workout_by_id() payload in the raw-data viewer
alongside activity/lap/detail JSON; enlarge the modal and shrink array
indentation for readability
- Fix "last sync" reporting a meaningless activity count: record one
combined sync run per manual "Sync now" and count genuinely new
activities instead of re-listing whatever Garmin returned for the queried
window
- Let a Review Queue activity be manually cleared back to Unclassified, and
make "Reset all" available even while disconnected from Garmin
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 06:33:47 +02:00
|
|
|
minPace, maxPace, hrMin, hrMax := 330.0, 420.0, 70.0, 80.0
|
2026-07-17 19:09:55 +02:00
|
|
|
body := map[string]any{
|
|
|
|
|
"name": target.Name,
|
|
|
|
|
"rule": json.RawMessage(`{"match":"all","conditions":[{"metric":"avg_pace_sec_per_km","op":"between","value":[270,300]}]}`),
|
|
|
|
|
"pace_min_sec_per_km": minPace,
|
|
|
|
|
"pace_max_sec_per_km": maxPace,
|
Dedup Garmin data storage, configurable chart colors, taxonomy fixes, sync/progression fixes
- Remove duplicated Garmin fields from storage; decode display-only fields
(activity name/type, lap duration/HR, structured workout raw JSON) from
RawJSON at API-response time instead of storing redundant columns
- Add a fully configurable chart color system (pace/HR main-line colors, 4
effort-kind colors, tint/darken/brighten intensity knobs) under Profile >
Chart colors
- Rename training types and fix their display order (Easy, Long, 60'/30'
Threshold, Tempo, Intervals, MAS Test, Race) everywhere they're listed
- Add an Efficiency Factor progression metric; fix Progression chart axes to
use tight non-zero-based domains, m:ss/km pace formatting, and rounded
ticks instead of raw floating-point labels
- Expose the raw get_workout_by_id() payload in the raw-data viewer
alongside activity/lap/detail JSON; enlarge the modal and shrink array
indentation for readability
- Fix "last sync" reporting a meaningless activity count: record one
combined sync run per manual "Sync now" and count genuinely new
activities instead of re-listing whatever Garmin returned for the queried
window
- Let a Review Queue activity be manually cleared back to Unclassified, and
make "Reset all" available even while disconnected from Garmin
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 06:33:47 +02:00
|
|
|
"hr_min_pct_hrr": hrMin,
|
|
|
|
|
"hr_max_pct_hrr": hrMax,
|
2026-07-17 19:09:55 +02:00
|
|
|
}
|
|
|
|
|
rec = doJSON(t, router, http.MethodPut, "/api/workout-kinds/"+itoa(target.ID), body)
|
|
|
|
|
if rec.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("update status = %d, body = %s", rec.Code, rec.Body.String())
|
2026-07-17 18:33:06 +02:00
|
|
|
}
|
|
|
|
|
|
2026-07-17 19:09:55 +02:00
|
|
|
rec = doJSON(t, router, http.MethodGet, "/api/workout-kinds/"+itoa(target.ID), nil)
|
|
|
|
|
var updated workoutKindResponse
|
|
|
|
|
json.Unmarshal(rec.Body.Bytes(), &updated)
|
|
|
|
|
if updated.PaceMinSecPerKm == nil || *updated.PaceMinSecPerKm != 330 {
|
|
|
|
|
t.Errorf("PaceMinSecPerKm = %v, want 330", updated.PaceMinSecPerKm)
|
2026-07-17 18:33:06 +02:00
|
|
|
}
|
Dedup Garmin data storage, configurable chart colors, taxonomy fixes, sync/progression fixes
- Remove duplicated Garmin fields from storage; decode display-only fields
(activity name/type, lap duration/HR, structured workout raw JSON) from
RawJSON at API-response time instead of storing redundant columns
- Add a fully configurable chart color system (pace/HR main-line colors, 4
effort-kind colors, tint/darken/brighten intensity knobs) under Profile >
Chart colors
- Rename training types and fix their display order (Easy, Long, 60'/30'
Threshold, Tempo, Intervals, MAS Test, Race) everywhere they're listed
- Add an Efficiency Factor progression metric; fix Progression chart axes to
use tight non-zero-based domains, m:ss/km pace formatting, and rounded
ticks instead of raw floating-point labels
- Expose the raw get_workout_by_id() payload in the raw-data viewer
alongside activity/lap/detail JSON; enlarge the modal and shrink array
indentation for readability
- Fix "last sync" reporting a meaningless activity count: record one
combined sync run per manual "Sync now" and count genuinely new
activities instead of re-listing whatever Garmin returned for the queried
window
- Let a Review Queue activity be manually cleared back to Unclassified, and
make "Reset all" available even while disconnected from Garmin
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 06:33:47 +02:00
|
|
|
if updated.HRMinPctHRR == nil || *updated.HRMinPctHRR != 70 {
|
|
|
|
|
t.Errorf("HRMinPctHRR = %v, want 70", updated.HRMinPctHRR)
|
|
|
|
|
}
|
|
|
|
|
if updated.HRMaxPctHRR == nil || *updated.HRMaxPctHRR != 80 {
|
|
|
|
|
t.Errorf("HRMaxPctHRR = %v, want 80", updated.HRMaxPctHRR)
|
2026-07-17 18:33:06 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-17 19:09:55 +02:00
|
|
|
func TestWorkoutKindUpdate_RejectsInvalidRule(t *testing.T) {
|
2026-07-17 18:33:06 +02:00
|
|
|
s, _ := newTestServer(t)
|
2026-07-17 19:09:55 +02:00
|
|
|
router := s.Router()
|
|
|
|
|
|
|
|
|
|
rec := doJSON(t, router, http.MethodGet, "/api/workout-kinds/", nil)
|
|
|
|
|
var kinds []workoutKindResponse
|
|
|
|
|
json.Unmarshal(rec.Body.Bytes(), &kinds)
|
|
|
|
|
|
|
|
|
|
rec = doJSON(t, router, http.MethodPut, "/api/workout-kinds/"+itoa(kinds[0].ID), map[string]any{
|
|
|
|
|
"name": kinds[0].Name,
|
2026-07-17 18:33:06 +02:00
|
|
|
"rule": json.RawMessage(`{"match":"xor","conditions":[]}`),
|
|
|
|
|
})
|
|
|
|
|
if rec.Code != http.StatusBadRequest {
|
|
|
|
|
t.Fatalf("status = %d, want 400, body = %s", rec.Code, rec.Body.String())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
Dedup Garmin data storage, configurable chart colors, taxonomy fixes, sync/progression fixes
- Remove duplicated Garmin fields from storage; decode display-only fields
(activity name/type, lap duration/HR, structured workout raw JSON) from
RawJSON at API-response time instead of storing redundant columns
- Add a fully configurable chart color system (pace/HR main-line colors, 4
effort-kind colors, tint/darken/brighten intensity knobs) under Profile >
Chart colors
- Rename training types and fix their display order (Easy, Long, 60'/30'
Threshold, Tempo, Intervals, MAS Test, Race) everywhere they're listed
- Add an Efficiency Factor progression metric; fix Progression chart axes to
use tight non-zero-based domains, m:ss/km pace formatting, and rounded
ticks instead of raw floating-point labels
- Expose the raw get_workout_by_id() payload in the raw-data viewer
alongside activity/lap/detail JSON; enlarge the modal and shrink array
indentation for readability
- Fix "last sync" reporting a meaningless activity count: record one
combined sync run per manual "Sync now" and count genuinely new
activities instead of re-listing whatever Garmin returned for the queried
window
- Let a Review Queue activity be manually cleared back to Unclassified, and
make "Reset all" available even while disconnected from Garmin
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 06:33:47 +02:00
|
|
|
func TestWorkoutKindUpdate_RejectsInvertedHRRange(t *testing.T) {
|
|
|
|
|
s, _ := newTestServer(t)
|
|
|
|
|
router := s.Router()
|
|
|
|
|
|
|
|
|
|
rec := doJSON(t, router, http.MethodGet, "/api/workout-kinds/", nil)
|
|
|
|
|
var kinds []workoutKindResponse
|
|
|
|
|
json.Unmarshal(rec.Body.Bytes(), &kinds)
|
|
|
|
|
|
|
|
|
|
rec = doJSON(t, router, http.MethodPut, "/api/workout-kinds/"+itoa(kinds[0].ID), map[string]any{
|
|
|
|
|
"name": kinds[0].Name,
|
|
|
|
|
"rule": json.RawMessage(`{"match":"all","conditions":[]}`),
|
|
|
|
|
"hr_min_pct_hrr": 80,
|
|
|
|
|
"hr_max_pct_hrr": 70,
|
|
|
|
|
})
|
|
|
|
|
if rec.Code != http.StatusBadRequest {
|
|
|
|
|
t.Fatalf("status = %d, want 400, body = %s", rec.Code, rec.Body.String())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestWorkoutKindUpdate_RejectsInvertedPaceRange(t *testing.T) {
|
2026-07-17 19:09:55 +02:00
|
|
|
s, _ := newTestServer(t)
|
|
|
|
|
router := s.Router()
|
|
|
|
|
|
|
|
|
|
rec := doJSON(t, router, http.MethodGet, "/api/workout-kinds/", nil)
|
|
|
|
|
var kinds []workoutKindResponse
|
|
|
|
|
json.Unmarshal(rec.Body.Bytes(), &kinds)
|
|
|
|
|
|
|
|
|
|
rec = doJSON(t, router, http.MethodPut, "/api/workout-kinds/"+itoa(kinds[0].ID), map[string]any{
|
Dedup Garmin data storage, configurable chart colors, taxonomy fixes, sync/progression fixes
- Remove duplicated Garmin fields from storage; decode display-only fields
(activity name/type, lap duration/HR, structured workout raw JSON) from
RawJSON at API-response time instead of storing redundant columns
- Add a fully configurable chart color system (pace/HR main-line colors, 4
effort-kind colors, tint/darken/brighten intensity knobs) under Profile >
Chart colors
- Rename training types and fix their display order (Easy, Long, 60'/30'
Threshold, Tempo, Intervals, MAS Test, Race) everywhere they're listed
- Add an Efficiency Factor progression metric; fix Progression chart axes to
use tight non-zero-based domains, m:ss/km pace formatting, and rounded
ticks instead of raw floating-point labels
- Expose the raw get_workout_by_id() payload in the raw-data viewer
alongside activity/lap/detail JSON; enlarge the modal and shrink array
indentation for readability
- Fix "last sync" reporting a meaningless activity count: record one
combined sync run per manual "Sync now" and count genuinely new
activities instead of re-listing whatever Garmin returned for the queried
window
- Let a Review Queue activity be manually cleared back to Unclassified, and
make "Reset all" available even while disconnected from Garmin
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 06:33:47 +02:00
|
|
|
"name": kinds[0].Name,
|
|
|
|
|
"rule": json.RawMessage(`{"match":"all","conditions":[]}`),
|
|
|
|
|
"pace_min_sec_per_km": 420,
|
|
|
|
|
"pace_max_sec_per_km": 330,
|
2026-07-17 19:09:55 +02:00
|
|
|
})
|
|
|
|
|
if rec.Code != http.StatusBadRequest {
|
|
|
|
|
t.Fatalf("status = %d, want 400, body = %s", rec.Code, rec.Body.String())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-17 18:33:06 +02:00
|
|
|
func TestReviewQueueResolve(t *testing.T) {
|
|
|
|
|
s, db := newTestServer(t)
|
|
|
|
|
ctx := newCtx()
|
|
|
|
|
|
Dedup Garmin data storage, configurable chart colors, taxonomy fixes, sync/progression fixes
- Remove duplicated Garmin fields from storage; decode display-only fields
(activity name/type, lap duration/HR, structured workout raw JSON) from
RawJSON at API-response time instead of storing redundant columns
- Add a fully configurable chart color system (pace/HR main-line colors, 4
effort-kind colors, tint/darken/brighten intensity knobs) under Profile >
Chart colors
- Rename training types and fix their display order (Easy, Long, 60'/30'
Threshold, Tempo, Intervals, MAS Test, Race) everywhere they're listed
- Add an Efficiency Factor progression metric; fix Progression chart axes to
use tight non-zero-based domains, m:ss/km pace formatting, and rounded
ticks instead of raw floating-point labels
- Expose the raw get_workout_by_id() payload in the raw-data viewer
alongside activity/lap/detail JSON; enlarge the modal and shrink array
indentation for readability
- Fix "last sync" reporting a meaningless activity count: record one
combined sync run per manual "Sync now" and count genuinely new
activities instead of re-listing whatever Garmin returned for the queried
window
- Let a Review Queue activity be manually cleared back to Unclassified, and
make "Reset all" available even while disconnected from Garmin
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 06:33:47 +02:00
|
|
|
activityID, err := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"})
|
2026-07-17 18:33:06 +02:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("UpsertActivity: %v", err)
|
|
|
|
|
}
|
Dedup Garmin data storage, configurable chart colors, taxonomy fixes, sync/progression fixes
- Remove duplicated Garmin fields from storage; decode display-only fields
(activity name/type, lap duration/HR, structured workout raw JSON) from
RawJSON at API-response time instead of storing redundant columns
- Add a fully configurable chart color system (pace/HR main-line colors, 4
effort-kind colors, tint/darken/brighten intensity knobs) under Profile >
Chart colors
- Rename training types and fix their display order (Easy, Long, 60'/30'
Threshold, Tempo, Intervals, MAS Test, Race) everywhere they're listed
- Add an Efficiency Factor progression metric; fix Progression chart axes to
use tight non-zero-based domains, m:ss/km pace formatting, and rounded
ticks instead of raw floating-point labels
- Expose the raw get_workout_by_id() payload in the raw-data viewer
alongside activity/lap/detail JSON; enlarge the modal and shrink array
indentation for readability
- Fix "last sync" reporting a meaningless activity count: record one
combined sync run per manual "Sync now" and count genuinely new
activities instead of re-listing whatever Garmin returned for the queried
window
- Let a Review Queue activity be manually cleared back to Unclassified, and
make "Reset all" available even while disconnected from Garmin
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 06:33:47 +02:00
|
|
|
kindID, err := db.CreateWorkoutKind(ctx, store.WorkoutKind{Name: "Test Kind", RuleJSON: `{"match":"all","conditions":[]}`, IsActive: true})
|
2026-07-17 18:33:06 +02:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("CreateWorkoutKind: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if _, err := db.InsertKindAssignment(ctx, store.KindAssignment{
|
|
|
|
|
ActivityID: activityID, AssignmentSource: store.AssignmentSourceRuleEngine, Status: store.AssignmentStatusNeedsReview,
|
|
|
|
|
CandidateKindsJSON: "[]",
|
|
|
|
|
}); err != nil {
|
|
|
|
|
t.Fatalf("InsertKindAssignment: %v", err)
|
|
|
|
|
}
|
2026-07-19 11:55:13 +02:00
|
|
|
targetLow, targetHigh := 3.0, 3.5
|
|
|
|
|
if err := db.ReplaceLaps(ctx, activityID, []store.Lap{
|
|
|
|
|
{LapIndex: 1, TargetPaceLowMps: &targetLow, TargetPaceHighMps: &targetHigh},
|
|
|
|
|
}); err != nil {
|
|
|
|
|
t.Fatalf("ReplaceLaps: %v", err)
|
|
|
|
|
}
|
2026-07-19 15:25:48 +02:00
|
|
|
hr := 150.0
|
|
|
|
|
if err := db.ReplaceActivitySamples(ctx, activityID, []store.Sample{
|
|
|
|
|
{ElapsedSeconds: 0, HeartRate: &hr},
|
|
|
|
|
}); err != nil {
|
|
|
|
|
t.Fatalf("ReplaceActivitySamples: %v", err)
|
|
|
|
|
}
|
2026-07-17 18:33:06 +02:00
|
|
|
|
|
|
|
|
router := s.Router()
|
|
|
|
|
rec := doJSON(t, router, http.MethodGet, "/api/review-queue/", nil)
|
|
|
|
|
if rec.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("review queue status = %d", rec.Code)
|
|
|
|
|
}
|
2026-07-19 16:31:24 +02:00
|
|
|
var page struct {
|
|
|
|
|
Items []map[string]any `json:"items"`
|
|
|
|
|
NextCursor *string `json:"next_cursor"`
|
|
|
|
|
Total int `json:"total"`
|
2026-07-17 18:33:06 +02:00
|
|
|
}
|
2026-07-19 16:31:24 +02:00
|
|
|
json.Unmarshal(rec.Body.Bytes(), &page)
|
|
|
|
|
if len(page.Items) != 1 || page.Total != 1 {
|
|
|
|
|
t.Fatalf("expected 1 item in review queue (total=1), got %d items, total=%d: %s", len(page.Items), page.Total, rec.Body.String())
|
|
|
|
|
}
|
|
|
|
|
laps, _ := page.Items[0]["laps"].([]any)
|
2026-07-19 11:55:13 +02:00
|
|
|
if len(laps) != 1 {
|
|
|
|
|
t.Fatalf("expected 1 lap in review queue item, got %d: %s", len(laps), rec.Body.String())
|
|
|
|
|
}
|
2026-07-19 16:31:24 +02:00
|
|
|
samples, _ := page.Items[0]["samples"].([]any)
|
2026-07-19 15:25:48 +02:00
|
|
|
if len(samples) != 1 {
|
|
|
|
|
t.Fatalf("expected 1 sample in review queue item, got %d: %s", len(samples), rec.Body.String())
|
|
|
|
|
}
|
2026-07-17 18:33:06 +02:00
|
|
|
|
|
|
|
|
rec = doJSON(t, router, http.MethodPost, "/api/review-queue/"+itoa(activityID)+"/resolve", map[string]any{"workout_kind_id": kindID})
|
|
|
|
|
if rec.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("resolve status = %d, body = %s", rec.Code, rec.Body.String())
|
|
|
|
|
}
|
|
|
|
|
|
Dedup Garmin data storage, configurable chart colors, taxonomy fixes, sync/progression fixes
- Remove duplicated Garmin fields from storage; decode display-only fields
(activity name/type, lap duration/HR, structured workout raw JSON) from
RawJSON at API-response time instead of storing redundant columns
- Add a fully configurable chart color system (pace/HR main-line colors, 4
effort-kind colors, tint/darken/brighten intensity knobs) under Profile >
Chart colors
- Rename training types and fix their display order (Easy, Long, 60'/30'
Threshold, Tempo, Intervals, MAS Test, Race) everywhere they're listed
- Add an Efficiency Factor progression metric; fix Progression chart axes to
use tight non-zero-based domains, m:ss/km pace formatting, and rounded
ticks instead of raw floating-point labels
- Expose the raw get_workout_by_id() payload in the raw-data viewer
alongside activity/lap/detail JSON; enlarge the modal and shrink array
indentation for readability
- Fix "last sync" reporting a meaningless activity count: record one
combined sync run per manual "Sync now" and count genuinely new
activities instead of re-listing whatever Garmin returned for the queried
window
- Let a Review Queue activity be manually cleared back to Unclassified, and
make "Reset all" available even while disconnected from Garmin
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 06:33:47 +02:00
|
|
|
// The activity stays listed after resolving -- the Activities page shows
|
|
|
|
|
// every activity, locked or not, not just ones still needing review.
|
2026-07-17 18:33:06 +02:00
|
|
|
rec = doJSON(t, router, http.MethodGet, "/api/review-queue/", nil)
|
2026-07-19 16:31:24 +02:00
|
|
|
json.Unmarshal(rec.Body.Bytes(), &page)
|
Dedup Garmin data storage, configurable chart colors, taxonomy fixes, sync/progression fixes
- Remove duplicated Garmin fields from storage; decode display-only fields
(activity name/type, lap duration/HR, structured workout raw JSON) from
RawJSON at API-response time instead of storing redundant columns
- Add a fully configurable chart color system (pace/HR main-line colors, 4
effort-kind colors, tint/darken/brighten intensity knobs) under Profile >
Chart colors
- Rename training types and fix their display order (Easy, Long, 60'/30'
Threshold, Tempo, Intervals, MAS Test, Race) everywhere they're listed
- Add an Efficiency Factor progression metric; fix Progression chart axes to
use tight non-zero-based domains, m:ss/km pace formatting, and rounded
ticks instead of raw floating-point labels
- Expose the raw get_workout_by_id() payload in the raw-data viewer
alongside activity/lap/detail JSON; enlarge the modal and shrink array
indentation for readability
- Fix "last sync" reporting a meaningless activity count: record one
combined sync run per manual "Sync now" and count genuinely new
activities instead of re-listing whatever Garmin returned for the queried
window
- Let a Review Queue activity be manually cleared back to Unclassified, and
make "Reset all" available even while disconnected from Garmin
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 06:33:47 +02:00
|
|
|
if len(page.Items) != 1 || page.Total != 1 {
|
|
|
|
|
t.Fatalf("expected activity to remain listed after resolve, got %d items, total=%d", len(page.Items), page.Total)
|
|
|
|
|
}
|
|
|
|
|
if page.Items[0]["AssignmentSource"] != store.AssignmentSourceManual {
|
|
|
|
|
t.Fatalf("expected AssignmentSource=manual after resolve, got %v", page.Items[0]["AssignmentSource"])
|
|
|
|
|
}
|
|
|
|
|
if got := page.Items[0]["WorkoutKindID"]; got != float64(kindID) {
|
|
|
|
|
t.Fatalf("expected WorkoutKindID=%d after resolve, got %v", kindID, got)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Unlocking reverts the source to rule_engine but keeps the same kind,
|
|
|
|
|
// so a later reclassify pass is free to change it again.
|
|
|
|
|
rec = doJSON(t, router, http.MethodPost, "/api/review-queue/"+itoa(activityID)+"/unlock", nil)
|
|
|
|
|
if rec.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("unlock status = %d, body = %s", rec.Code, rec.Body.String())
|
|
|
|
|
}
|
|
|
|
|
rec = doJSON(t, router, http.MethodGet, "/api/review-queue/", nil)
|
|
|
|
|
json.Unmarshal(rec.Body.Bytes(), &page)
|
|
|
|
|
if page.Items[0]["AssignmentSource"] != store.AssignmentSourceRuleEngine {
|
|
|
|
|
t.Fatalf("expected AssignmentSource=rule_engine after unlock, got %v", page.Items[0]["AssignmentSource"])
|
|
|
|
|
}
|
|
|
|
|
if got := page.Items[0]["WorkoutKindID"]; got != float64(kindID) {
|
|
|
|
|
t.Fatalf("expected WorkoutKindID=%d to survive unlock, got %v", kindID, got)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Unlocking an already-unlocked activity is rejected.
|
|
|
|
|
rec = doJSON(t, router, http.MethodPost, "/api/review-queue/"+itoa(activityID)+"/unlock", nil)
|
|
|
|
|
if rec.Code != http.StatusBadRequest {
|
|
|
|
|
t.Fatalf("expected 400 unlocking a non-manual assignment, got %d", rec.Code)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Manually unassigning clears the kind back to Unclassified and locks
|
|
|
|
|
// that decision (source=manual), same as resolving to a specific kind.
|
|
|
|
|
rec = doJSON(t, router, http.MethodPost, "/api/review-queue/"+itoa(activityID)+"/unassign", nil)
|
|
|
|
|
if rec.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("unassign status = %d, body = %s", rec.Code, rec.Body.String())
|
|
|
|
|
}
|
|
|
|
|
rec = doJSON(t, router, http.MethodGet, "/api/review-queue/", nil)
|
|
|
|
|
json.Unmarshal(rec.Body.Bytes(), &page)
|
|
|
|
|
if page.Items[0]["AssignmentSource"] != store.AssignmentSourceManual {
|
|
|
|
|
t.Fatalf("expected AssignmentSource=manual after unassign, got %v", page.Items[0]["AssignmentSource"])
|
|
|
|
|
}
|
|
|
|
|
if got := page.Items[0]["WorkoutKindID"]; got != nil {
|
|
|
|
|
t.Fatalf("expected WorkoutKindID=nil after unassign, got %v", got)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// It also shows up under the Unclassified filter now.
|
|
|
|
|
rec = doJSON(t, router, http.MethodGet, "/api/review-queue/?unclassified=true", nil)
|
|
|
|
|
json.Unmarshal(rec.Body.Bytes(), &page)
|
|
|
|
|
if len(page.Items) != 1 || page.Total != 1 {
|
|
|
|
|
t.Fatalf("expected unassigned activity to show up as unclassified, got %d items, total=%d", len(page.Items), page.Total)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Unassigning locks it, so unlocking works again (reverting to
|
|
|
|
|
// rule_engine sourcing with no kind, i.e. needs_review).
|
|
|
|
|
rec = doJSON(t, router, http.MethodPost, "/api/review-queue/"+itoa(activityID)+"/unlock", nil)
|
|
|
|
|
if rec.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("unlock after unassign status = %d, body = %s", rec.Code, rec.Body.String())
|
|
|
|
|
}
|
|
|
|
|
rec = doJSON(t, router, http.MethodGet, "/api/review-queue/", nil)
|
|
|
|
|
json.Unmarshal(rec.Body.Bytes(), &page)
|
|
|
|
|
if page.Items[0]["AssignmentSource"] != store.AssignmentSourceRuleEngine {
|
|
|
|
|
t.Fatalf("expected AssignmentSource=rule_engine after unlock, got %v", page.Items[0]["AssignmentSource"])
|
2026-07-19 16:31:24 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestReviewQueue_PaginatesByCursor(t *testing.T) {
|
|
|
|
|
s, db := newTestServer(t)
|
|
|
|
|
ctx := newCtx()
|
|
|
|
|
|
|
|
|
|
// 5 activities, newest first once sorted: 2026-07-05 .. 2026-07-01.
|
|
|
|
|
for i := 1; i <= 5; i++ {
|
|
|
|
|
activityID, err := db.UpsertActivity(ctx, store.Activity{
|
Dedup Garmin data storage, configurable chart colors, taxonomy fixes, sync/progression fixes
- Remove duplicated Garmin fields from storage; decode display-only fields
(activity name/type, lap duration/HR, structured workout raw JSON) from
RawJSON at API-response time instead of storing redundant columns
- Add a fully configurable chart color system (pace/HR main-line colors, 4
effort-kind colors, tint/darken/brighten intensity knobs) under Profile >
Chart colors
- Rename training types and fix their display order (Easy, Long, 60'/30'
Threshold, Tempo, Intervals, MAS Test, Race) everywhere they're listed
- Add an Efficiency Factor progression metric; fix Progression chart axes to
use tight non-zero-based domains, m:ss/km pace formatting, and rounded
ticks instead of raw floating-point labels
- Expose the raw get_workout_by_id() payload in the raw-data viewer
alongside activity/lap/detail JSON; enlarge the modal and shrink array
indentation for readability
- Fix "last sync" reporting a meaningless activity count: record one
combined sync run per manual "Sync now" and count genuinely new
activities instead of re-listing whatever Garmin returned for the queried
window
- Let a Review Queue activity be manually cleared back to Unclassified, and
make "Reset all" available even while disconnected from Garmin
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 06:33:47 +02:00
|
|
|
GarminActivityID: int64(i),
|
2026-07-19 16:31:24 +02:00
|
|
|
StartTimeUTC: fmt.Sprintf("2026-07-0%d 06:00:00", i), RawJSON: "{}",
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("UpsertActivity: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if _, err := db.InsertKindAssignment(ctx, store.KindAssignment{
|
|
|
|
|
ActivityID: activityID, AssignmentSource: store.AssignmentSourceRuleEngine, Status: store.AssignmentStatusNeedsReview,
|
|
|
|
|
CandidateKindsJSON: "[]",
|
|
|
|
|
}); err != nil {
|
|
|
|
|
t.Fatalf("InsertKindAssignment: %v", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
router := s.Router()
|
|
|
|
|
|
|
|
|
|
type page struct {
|
|
|
|
|
Items []map[string]any `json:"items"`
|
|
|
|
|
NextCursor *string `json:"next_cursor"`
|
|
|
|
|
Total int `json:"total"`
|
|
|
|
|
}
|
|
|
|
|
getPage := func(query string) page {
|
|
|
|
|
t.Helper()
|
|
|
|
|
rec := doJSON(t, router, http.MethodGet, "/api/review-queue/"+query, nil)
|
|
|
|
|
if rec.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
|
|
|
|
}
|
|
|
|
|
var p page
|
|
|
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &p); err != nil {
|
|
|
|
|
t.Fatalf("unmarshal: %v", err)
|
|
|
|
|
}
|
|
|
|
|
return p
|
|
|
|
|
}
|
|
|
|
|
startTime := func(item map[string]any) string {
|
|
|
|
|
return item["activity"].(map[string]any)["StartTimeUTC"].(string)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
first := getPage("?limit=2")
|
|
|
|
|
if len(first.Items) != 2 || first.Total != 5 {
|
|
|
|
|
t.Fatalf("first page: expected 2 items (total=5), got %d items, total=%d", len(first.Items), first.Total)
|
|
|
|
|
}
|
|
|
|
|
if startTime(first.Items[0]) != "2026-07-05 06:00:00" || startTime(first.Items[1]) != "2026-07-04 06:00:00" {
|
|
|
|
|
t.Fatalf("first page not newest-first: %v", first.Items)
|
|
|
|
|
}
|
|
|
|
|
if first.NextCursor == nil {
|
|
|
|
|
t.Fatal("expected a next_cursor on the first page")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
second := getPage("?limit=2&before=" + url.QueryEscape(*first.NextCursor))
|
|
|
|
|
if len(second.Items) != 2 {
|
|
|
|
|
t.Fatalf("second page: expected 2 items, got %d", len(second.Items))
|
|
|
|
|
}
|
|
|
|
|
if startTime(second.Items[0]) != "2026-07-03 06:00:00" || startTime(second.Items[1]) != "2026-07-02 06:00:00" {
|
|
|
|
|
t.Fatalf("second page not continuing newest-first: %v", second.Items)
|
|
|
|
|
}
|
|
|
|
|
if second.NextCursor == nil {
|
|
|
|
|
t.Fatal("expected a next_cursor on the second page")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
third := getPage("?limit=2&before=" + url.QueryEscape(*second.NextCursor))
|
|
|
|
|
if len(third.Items) != 1 {
|
|
|
|
|
t.Fatalf("third page: expected 1 remaining item, got %d", len(third.Items))
|
|
|
|
|
}
|
|
|
|
|
if startTime(third.Items[0]) != "2026-07-01 06:00:00" {
|
|
|
|
|
t.Fatalf("third page wrong item: %v", third.Items)
|
|
|
|
|
}
|
|
|
|
|
if third.NextCursor != nil {
|
|
|
|
|
t.Fatalf("expected no next_cursor on the last page, got %v", *third.NextCursor)
|
2026-07-17 18:33:06 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
Dedup Garmin data storage, configurable chart colors, taxonomy fixes, sync/progression fixes
- Remove duplicated Garmin fields from storage; decode display-only fields
(activity name/type, lap duration/HR, structured workout raw JSON) from
RawJSON at API-response time instead of storing redundant columns
- Add a fully configurable chart color system (pace/HR main-line colors, 4
effort-kind colors, tint/darken/brighten intensity knobs) under Profile >
Chart colors
- Rename training types and fix their display order (Easy, Long, 60'/30'
Threshold, Tempo, Intervals, MAS Test, Race) everywhere they're listed
- Add an Efficiency Factor progression metric; fix Progression chart axes to
use tight non-zero-based domains, m:ss/km pace formatting, and rounded
ticks instead of raw floating-point labels
- Expose the raw get_workout_by_id() payload in the raw-data viewer
alongside activity/lap/detail JSON; enlarge the modal and shrink array
indentation for readability
- Fix "last sync" reporting a meaningless activity count: record one
combined sync run per manual "Sync now" and count genuinely new
activities instead of re-listing whatever Garmin returned for the queried
window
- Let a Review Queue activity be manually cleared back to Unclassified, and
make "Reset all" available even while disconnected from Garmin
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 06:33:47 +02:00
|
|
|
func TestReviewQueue_FiltersByKindAndUnclassifiedStayPaginated(t *testing.T) {
|
|
|
|
|
s, db := newTestServer(t)
|
|
|
|
|
ctx := newCtx()
|
|
|
|
|
|
|
|
|
|
kinds, err := db.ListWorkoutKinds(ctx, true)
|
|
|
|
|
if err != nil || len(kinds) < 2 {
|
|
|
|
|
t.Fatalf("ListWorkoutKinds: %v (len=%d)", err, len(kinds))
|
|
|
|
|
}
|
|
|
|
|
kindA, kindB := kinds[0], kinds[1]
|
|
|
|
|
|
|
|
|
|
// 2 activities assigned to kindA, 1 to kindB, 1 unclassified.
|
|
|
|
|
makeActivity := func(n int64, kindID *int64) {
|
|
|
|
|
activityID, err := db.UpsertActivity(ctx, store.Activity{
|
|
|
|
|
GarminActivityID: n,
|
|
|
|
|
StartTimeUTC: fmt.Sprintf("2026-07-0%d 06:00:00", n), RawJSON: "{}",
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("UpsertActivity: %v", err)
|
|
|
|
|
}
|
|
|
|
|
status := store.AssignmentStatusAssigned
|
|
|
|
|
if kindID == nil {
|
|
|
|
|
status = store.AssignmentStatusNeedsReview
|
|
|
|
|
}
|
|
|
|
|
if _, err := db.InsertKindAssignment(ctx, store.KindAssignment{
|
|
|
|
|
ActivityID: activityID, WorkoutKindID: kindID, AssignmentSource: store.AssignmentSourceRuleEngine,
|
|
|
|
|
Status: status, CandidateKindsJSON: "[]",
|
|
|
|
|
}); err != nil {
|
|
|
|
|
t.Fatalf("InsertKindAssignment: %v", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
makeActivity(1, &kindA.ID)
|
|
|
|
|
makeActivity(2, &kindA.ID)
|
|
|
|
|
makeActivity(3, &kindB.ID)
|
|
|
|
|
makeActivity(4, nil)
|
|
|
|
|
|
|
|
|
|
router := s.Router()
|
|
|
|
|
|
|
|
|
|
type page struct {
|
|
|
|
|
Items []map[string]any `json:"items"`
|
|
|
|
|
NextCursor *string `json:"next_cursor"`
|
|
|
|
|
Total int `json:"total"`
|
|
|
|
|
}
|
|
|
|
|
getPage := func(query string) page {
|
|
|
|
|
t.Helper()
|
|
|
|
|
rec := doJSON(t, router, http.MethodGet, "/api/review-queue/"+query, nil)
|
|
|
|
|
if rec.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
|
|
|
|
}
|
|
|
|
|
var p page
|
|
|
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &p); err != nil {
|
|
|
|
|
t.Fatalf("unmarshal: %v", err)
|
|
|
|
|
}
|
|
|
|
|
return p
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Filtering by kind_id, with a small limit, still paginates -- it doesn't
|
|
|
|
|
// have to load the whole matching backlog to serve one page.
|
|
|
|
|
kindAPage := getPage(fmt.Sprintf("?kind_id=%d&limit=1", kindA.ID))
|
|
|
|
|
if kindAPage.Total != 2 {
|
|
|
|
|
t.Fatalf("kindA total = %d, want 2", kindAPage.Total)
|
|
|
|
|
}
|
|
|
|
|
if len(kindAPage.Items) != 1 {
|
|
|
|
|
t.Fatalf("kindA page: expected 1 item (limit=1), got %d", len(kindAPage.Items))
|
|
|
|
|
}
|
|
|
|
|
if kindAPage.NextCursor == nil {
|
|
|
|
|
t.Fatal("expected a next_cursor on kindA's first (limited) page")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
kindBPage := getPage(fmt.Sprintf("?kind_id=%d", kindB.ID))
|
|
|
|
|
if kindBPage.Total != 1 || len(kindBPage.Items) != 1 {
|
|
|
|
|
t.Fatalf("kindB page: total=%d items=%d, want 1/1", kindBPage.Total, len(kindBPage.Items))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
unclassifiedPage := getPage("?unclassified=true")
|
|
|
|
|
if unclassifiedPage.Total != 1 || len(unclassifiedPage.Items) != 1 {
|
|
|
|
|
t.Fatalf("unclassified page: total=%d items=%d, want 1/1", unclassifiedPage.Total, len(unclassifiedPage.Items))
|
|
|
|
|
}
|
|
|
|
|
if unclassifiedPage.Items[0]["WorkoutKindID"] != nil {
|
|
|
|
|
t.Fatalf("expected nil WorkoutKindID in unclassified filter, got %v", unclassifiedPage.Items[0]["WorkoutKindID"])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
all := getPage("")
|
|
|
|
|
if all.Total != 4 {
|
|
|
|
|
t.Fatalf("unfiltered total = %d, want 4", all.Total)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-19 11:11:50 +02:00
|
|
|
func TestResolveReview_RejectsRaceKind(t *testing.T) {
|
|
|
|
|
s, db := newTestServer(t)
|
|
|
|
|
ctx := newCtx()
|
|
|
|
|
|
Dedup Garmin data storage, configurable chart colors, taxonomy fixes, sync/progression fixes
- Remove duplicated Garmin fields from storage; decode display-only fields
(activity name/type, lap duration/HR, structured workout raw JSON) from
RawJSON at API-response time instead of storing redundant columns
- Add a fully configurable chart color system (pace/HR main-line colors, 4
effort-kind colors, tint/darken/brighten intensity knobs) under Profile >
Chart colors
- Rename training types and fix their display order (Easy, Long, 60'/30'
Threshold, Tempo, Intervals, MAS Test, Race) everywhere they're listed
- Add an Efficiency Factor progression metric; fix Progression chart axes to
use tight non-zero-based domains, m:ss/km pace formatting, and rounded
ticks instead of raw floating-point labels
- Expose the raw get_workout_by_id() payload in the raw-data viewer
alongside activity/lap/detail JSON; enlarge the modal and shrink array
indentation for readability
- Fix "last sync" reporting a meaningless activity count: record one
combined sync run per manual "Sync now" and count genuinely new
activities instead of re-listing whatever Garmin returned for the queried
window
- Let a Review Queue activity be manually cleared back to Unclassified, and
make "Reset all" available even while disconnected from Garmin
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 06:33:47 +02:00
|
|
|
activityID, err := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"})
|
2026-07-19 11:11:50 +02:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("UpsertActivity: %v", err)
|
|
|
|
|
}
|
|
|
|
|
raceKind, ok, err := db.GetWorkoutKindByName(ctx, "Race")
|
|
|
|
|
if err != nil || !ok {
|
|
|
|
|
t.Fatalf("GetWorkoutKindByName(Race): ok=%v err=%v", ok, err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
rec := doJSON(t, s.Router(), http.MethodPost, "/api/review-queue/"+itoa(activityID)+"/resolve", map[string]any{"workout_kind_id": raceKind.ID})
|
|
|
|
|
if rec.Code != http.StatusBadRequest {
|
|
|
|
|
t.Fatalf("resolve to Race status = %d, want 400, body = %s", rec.Code, rec.Body.String())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestReclassifyAll_SkipsManualAndRaceAssignments(t *testing.T) {
|
|
|
|
|
s, db := newTestServer(t)
|
|
|
|
|
ctx := newCtx()
|
|
|
|
|
|
|
|
|
|
easyID, err := db.CreateWorkoutKind(ctx, store.WorkoutKind{Name: "Test Easy", RuleJSON: `{"match":"all","conditions":[{"metric":"distance_meters","op":">=","value":0}]}`, IsActive: true})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("CreateWorkoutKind: %v", err)
|
|
|
|
|
}
|
|
|
|
|
raceKind, ok, err := db.GetWorkoutKindByName(ctx, "Race")
|
|
|
|
|
if err != nil || !ok {
|
|
|
|
|
t.Fatalf("GetWorkoutKindByName(Race): ok=%v err=%v", ok, err)
|
|
|
|
|
}
|
|
|
|
|
|
Dedup Garmin data storage, configurable chart colors, taxonomy fixes, sync/progression fixes
- Remove duplicated Garmin fields from storage; decode display-only fields
(activity name/type, lap duration/HR, structured workout raw JSON) from
RawJSON at API-response time instead of storing redundant columns
- Add a fully configurable chart color system (pace/HR main-line colors, 4
effort-kind colors, tint/darken/brighten intensity knobs) under Profile >
Chart colors
- Rename training types and fix their display order (Easy, Long, 60'/30'
Threshold, Tempo, Intervals, MAS Test, Race) everywhere they're listed
- Add an Efficiency Factor progression metric; fix Progression chart axes to
use tight non-zero-based domains, m:ss/km pace formatting, and rounded
ticks instead of raw floating-point labels
- Expose the raw get_workout_by_id() payload in the raw-data viewer
alongside activity/lap/detail JSON; enlarge the modal and shrink array
indentation for readability
- Fix "last sync" reporting a meaningless activity count: record one
combined sync run per manual "Sync now" and count genuinely new
activities instead of re-listing whatever Garmin returned for the queried
window
- Let a Review Queue activity be manually cleared back to Unclassified, and
make "Reset all" available even while disconnected from Garmin
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 06:33:47 +02:00
|
|
|
ruleEngineActivity, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-01 06:00:00", RawJSON: "{}"})
|
|
|
|
|
manualActivity, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 2, StartTimeUTC: "2026-07-02 06:00:00", RawJSON: "{}"})
|
|
|
|
|
raceActivity, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 3, EventTypeKey: "race", StartTimeUTC: "2026-07-03 06:00:00", RawJSON: "{}"})
|
2026-07-19 11:11:50 +02:00
|
|
|
|
|
|
|
|
if _, err := db.InsertKindAssignment(ctx, store.KindAssignment{
|
|
|
|
|
ActivityID: ruleEngineActivity, WorkoutKindID: &easyID, AssignmentSource: store.AssignmentSourceRuleEngine,
|
|
|
|
|
Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]",
|
|
|
|
|
}); err != nil {
|
|
|
|
|
t.Fatalf("InsertKindAssignment (rule engine): %v", err)
|
|
|
|
|
}
|
|
|
|
|
if _, err := db.InsertKindAssignment(ctx, store.KindAssignment{
|
|
|
|
|
ActivityID: manualActivity, WorkoutKindID: &easyID, AssignmentSource: store.AssignmentSourceManual,
|
|
|
|
|
Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]",
|
|
|
|
|
}); err != nil {
|
|
|
|
|
t.Fatalf("InsertKindAssignment (manual): %v", err)
|
|
|
|
|
}
|
|
|
|
|
if _, err := db.InsertKindAssignment(ctx, store.KindAssignment{
|
|
|
|
|
ActivityID: raceActivity, WorkoutKindID: &raceKind.ID, AssignmentSource: store.AssignmentSourceRuleEngine,
|
|
|
|
|
Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]",
|
|
|
|
|
}); err != nil {
|
|
|
|
|
t.Fatalf("InsertKindAssignment (race): %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
rec := doJSON(t, s.Router(), http.MethodPost, "/api/reclassify", nil)
|
|
|
|
|
if rec.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("reclassify status = %d, body = %s", rec.Code, rec.Body.String())
|
|
|
|
|
}
|
|
|
|
|
var body struct {
|
|
|
|
|
Reclassified int `json:"reclassified"`
|
|
|
|
|
}
|
|
|
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
|
|
|
t.Fatalf("unmarshal: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if body.Reclassified != 1 {
|
|
|
|
|
t.Fatalf("reclassified = %d, want 1 (only the non-locked rule-engine activity)", body.Reclassified)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
manualAssignment, ok, err := db.CurrentAssignment(ctx, manualActivity)
|
|
|
|
|
if err != nil || !ok {
|
|
|
|
|
t.Fatalf("CurrentAssignment(manual): ok=%v err=%v", ok, err)
|
|
|
|
|
}
|
|
|
|
|
if manualAssignment.AssignmentSource != store.AssignmentSourceManual {
|
|
|
|
|
t.Errorf("manual assignment was overwritten: %+v", manualAssignment)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
raceAssignment, ok, err := db.CurrentAssignment(ctx, raceActivity)
|
|
|
|
|
if err != nil || !ok {
|
|
|
|
|
t.Fatalf("CurrentAssignment(race): ok=%v err=%v", ok, err)
|
|
|
|
|
}
|
|
|
|
|
if raceAssignment.WorkoutKindID == nil || *raceAssignment.WorkoutKindID != raceKind.ID {
|
|
|
|
|
t.Errorf("race assignment changed: %+v", raceAssignment)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestListActivities_ReportsLockedForManualAndRaceAssignments(t *testing.T) {
|
|
|
|
|
s, db := newTestServer(t)
|
|
|
|
|
ctx := newCtx()
|
|
|
|
|
|
|
|
|
|
easyID, err := db.CreateWorkoutKind(ctx, store.WorkoutKind{Name: "Test Easy Locked", RuleJSON: `{"match":"all","conditions":[]}`, IsActive: true})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("CreateWorkoutKind: %v", err)
|
|
|
|
|
}
|
|
|
|
|
raceKind, ok, err := db.GetWorkoutKindByName(ctx, "Race")
|
|
|
|
|
if err != nil || !ok {
|
|
|
|
|
t.Fatalf("GetWorkoutKindByName(Race): ok=%v err=%v", ok, err)
|
|
|
|
|
}
|
|
|
|
|
|
Dedup Garmin data storage, configurable chart colors, taxonomy fixes, sync/progression fixes
- Remove duplicated Garmin fields from storage; decode display-only fields
(activity name/type, lap duration/HR, structured workout raw JSON) from
RawJSON at API-response time instead of storing redundant columns
- Add a fully configurable chart color system (pace/HR main-line colors, 4
effort-kind colors, tint/darken/brighten intensity knobs) under Profile >
Chart colors
- Rename training types and fix their display order (Easy, Long, 60'/30'
Threshold, Tempo, Intervals, MAS Test, Race) everywhere they're listed
- Add an Efficiency Factor progression metric; fix Progression chart axes to
use tight non-zero-based domains, m:ss/km pace formatting, and rounded
ticks instead of raw floating-point labels
- Expose the raw get_workout_by_id() payload in the raw-data viewer
alongside activity/lap/detail JSON; enlarge the modal and shrink array
indentation for readability
- Fix "last sync" reporting a meaningless activity count: record one
combined sync run per manual "Sync now" and count genuinely new
activities instead of re-listing whatever Garmin returned for the queried
window
- Let a Review Queue activity be manually cleared back to Unclassified, and
make "Reset all" available even while disconnected from Garmin
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 06:33:47 +02:00
|
|
|
ruleEngineActivity, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-01 06:00:00", RawJSON: "{}"})
|
|
|
|
|
manualActivity, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 2, StartTimeUTC: "2026-07-02 06:00:00", RawJSON: "{}"})
|
|
|
|
|
raceActivity, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 3, EventTypeKey: "race", StartTimeUTC: "2026-07-03 06:00:00", RawJSON: "{}"})
|
2026-07-19 11:11:50 +02:00
|
|
|
|
|
|
|
|
db.InsertKindAssignment(ctx, store.KindAssignment{ActivityID: ruleEngineActivity, WorkoutKindID: &easyID, AssignmentSource: store.AssignmentSourceRuleEngine, Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]"})
|
|
|
|
|
db.InsertKindAssignment(ctx, store.KindAssignment{ActivityID: manualActivity, WorkoutKindID: &easyID, AssignmentSource: store.AssignmentSourceManual, Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]"})
|
|
|
|
|
db.InsertKindAssignment(ctx, store.KindAssignment{ActivityID: raceActivity, WorkoutKindID: &raceKind.ID, AssignmentSource: store.AssignmentSourceRuleEngine, Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]"})
|
|
|
|
|
|
|
|
|
|
rec := doJSON(t, s.Router(), http.MethodGet, "/api/activities/", nil)
|
|
|
|
|
if rec.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
|
|
|
|
}
|
|
|
|
|
var items []activityListItem
|
|
|
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil {
|
|
|
|
|
t.Fatalf("unmarshal: %v", err)
|
|
|
|
|
}
|
|
|
|
|
locked := map[int64]bool{}
|
|
|
|
|
kindName := map[int64]string{}
|
|
|
|
|
for _, it := range items {
|
|
|
|
|
locked[it.ID] = it.Locked
|
|
|
|
|
if it.WorkoutKindName != nil {
|
|
|
|
|
kindName[it.ID] = *it.WorkoutKindName
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if locked[ruleEngineActivity] {
|
|
|
|
|
t.Errorf("rule-engine activity should not be locked")
|
|
|
|
|
}
|
|
|
|
|
if !locked[manualActivity] {
|
|
|
|
|
t.Errorf("manual activity should be locked")
|
|
|
|
|
}
|
|
|
|
|
if !locked[raceActivity] {
|
|
|
|
|
t.Errorf("race activity should be locked")
|
|
|
|
|
}
|
|
|
|
|
if kindName[raceActivity] != "Race" {
|
|
|
|
|
t.Errorf("race activity workout_kind_name = %q, want %q", kindName[raceActivity], "Race")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-19 12:41:38 +02:00
|
|
|
func TestSyncReset_DeletesActivitiesAndBackfillEndpointIsGone(t *testing.T) {
|
|
|
|
|
s, db := newTestServer(t)
|
|
|
|
|
ctx := newCtx()
|
|
|
|
|
|
Dedup Garmin data storage, configurable chart colors, taxonomy fixes, sync/progression fixes
- Remove duplicated Garmin fields from storage; decode display-only fields
(activity name/type, lap duration/HR, structured workout raw JSON) from
RawJSON at API-response time instead of storing redundant columns
- Add a fully configurable chart color system (pace/HR main-line colors, 4
effort-kind colors, tint/darken/brighten intensity knobs) under Profile >
Chart colors
- Rename training types and fix their display order (Easy, Long, 60'/30'
Threshold, Tempo, Intervals, MAS Test, Race) everywhere they're listed
- Add an Efficiency Factor progression metric; fix Progression chart axes to
use tight non-zero-based domains, m:ss/km pace formatting, and rounded
ticks instead of raw floating-point labels
- Expose the raw get_workout_by_id() payload in the raw-data viewer
alongside activity/lap/detail JSON; enlarge the modal and shrink array
indentation for readability
- Fix "last sync" reporting a meaningless activity count: record one
combined sync run per manual "Sync now" and count genuinely new
activities instead of re-listing whatever Garmin returned for the queried
window
- Let a Review Queue activity be manually cleared back to Unclassified, and
make "Reset all" available even while disconnected from Garmin
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 06:33:47 +02:00
|
|
|
if _, err := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"}); err != nil {
|
2026-07-19 12:41:38 +02:00
|
|
|
t.Fatalf("UpsertActivity: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
router := s.Router()
|
|
|
|
|
rec := doJSON(t, router, http.MethodPost, "/api/sync/reset", nil)
|
|
|
|
|
if rec.Code != http.StatusAccepted {
|
|
|
|
|
t.Fatalf("reset status = %d, body = %s", rec.Code, rec.Body.String())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
|
|
|
for {
|
|
|
|
|
activities, err := db.ListActivities(ctx, store.ActivityFilter{})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("ListActivities: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if len(activities) == 0 {
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
if time.Now().After(deadline) {
|
|
|
|
|
t.Fatalf("timed out waiting for reset to delete activities, still have %d", len(activities))
|
|
|
|
|
}
|
|
|
|
|
time.Sleep(10 * time.Millisecond)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// "Full backfill" no longer exists as an endpoint -- superseded by reset.
|
|
|
|
|
rec = doJSON(t, router, http.MethodPost, "/api/sync/backfill", nil)
|
|
|
|
|
if rec.Code != http.StatusNotFound {
|
|
|
|
|
t.Errorf("/api/sync/backfill status = %d, want 404 (removed)", rec.Code)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-17 18:33:06 +02:00
|
|
|
func TestProgression_ReturnsSortedTimeSeries(t *testing.T) {
|
|
|
|
|
s, db := newTestServer(t)
|
|
|
|
|
ctx := newCtx()
|
|
|
|
|
|
Dedup Garmin data storage, configurable chart colors, taxonomy fixes, sync/progression fixes
- Remove duplicated Garmin fields from storage; decode display-only fields
(activity name/type, lap duration/HR, structured workout raw JSON) from
RawJSON at API-response time instead of storing redundant columns
- Add a fully configurable chart color system (pace/HR main-line colors, 4
effort-kind colors, tint/darken/brighten intensity knobs) under Profile >
Chart colors
- Rename training types and fix their display order (Easy, Long, 60'/30'
Threshold, Tempo, Intervals, MAS Test, Race) everywhere they're listed
- Add an Efficiency Factor progression metric; fix Progression chart axes to
use tight non-zero-based domains, m:ss/km pace formatting, and rounded
ticks instead of raw floating-point labels
- Expose the raw get_workout_by_id() payload in the raw-data viewer
alongside activity/lap/detail JSON; enlarge the modal and shrink array
indentation for readability
- Fix "last sync" reporting a meaningless activity count: record one
combined sync run per manual "Sync now" and count genuinely new
activities instead of re-listing whatever Garmin returned for the queried
window
- Let a Review Queue activity be manually cleared back to Unclassified, and
make "Reset all" available even while disconnected from Garmin
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 06:33:47 +02:00
|
|
|
kindID, _ := db.CreateWorkoutKind(ctx, store.WorkoutKind{Name: "Test Kind", RuleJSON: `{}`, IsActive: true})
|
2026-07-17 18:33:06 +02:00
|
|
|
|
|
|
|
|
speed := 3.0
|
Dedup Garmin data storage, configurable chart colors, taxonomy fixes, sync/progression fixes
- Remove duplicated Garmin fields from storage; decode display-only fields
(activity name/type, lap duration/HR, structured workout raw JSON) from
RawJSON at API-response time instead of storing redundant columns
- Add a fully configurable chart color system (pace/HR main-line colors, 4
effort-kind colors, tint/darken/brighten intensity knobs) under Profile >
Chart colors
- Rename training types and fix their display order (Easy, Long, 60'/30'
Threshold, Tempo, Intervals, MAS Test, Race) everywhere they're listed
- Add an Efficiency Factor progression metric; fix Progression chart axes to
use tight non-zero-based domains, m:ss/km pace formatting, and rounded
ticks instead of raw floating-point labels
- Expose the raw get_workout_by_id() payload in the raw-data viewer
alongside activity/lap/detail JSON; enlarge the modal and shrink array
indentation for readability
- Fix "last sync" reporting a meaningless activity count: record one
combined sync run per manual "Sync now" and count genuinely new
activities instead of re-listing whatever Garmin returned for the queried
window
- Let a Review Queue activity be manually cleared back to Unclassified, and
make "Reset all" available even while disconnected from Garmin
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 06:33:47 +02:00
|
|
|
a1, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-01 06:00:00", AvgSpeedMps: &speed, RawJSON: "{}"})
|
|
|
|
|
a2, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 2, StartTimeUTC: "2026-07-05 06:00:00", AvgSpeedMps: &speed, RawJSON: "{}"})
|
2026-07-17 18:33:06 +02:00
|
|
|
|
|
|
|
|
for _, id := range []int64{a2, a1} { // insert out of order on purpose
|
|
|
|
|
db.InsertKindAssignment(ctx, store.KindAssignment{ActivityID: id, WorkoutKindID: &kindID, AssignmentSource: store.AssignmentSourceManual, Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]"})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
rec := doJSON(t, s.Router(), http.MethodGet, "/api/progression/"+itoa(kindID)+"?metric=pace", nil)
|
|
|
|
|
if rec.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
|
|
|
|
}
|
|
|
|
|
var points []progressionPoint
|
|
|
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &points); err != nil {
|
|
|
|
|
t.Fatalf("unmarshal: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if len(points) != 2 {
|
|
|
|
|
t.Fatalf("expected 2 points, got %d", len(points))
|
|
|
|
|
}
|
|
|
|
|
if points[0].Date > points[1].Date {
|
|
|
|
|
t.Errorf("points not sorted ascending by date: %+v", points)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
Dedup Garmin data storage, configurable chart colors, taxonomy fixes, sync/progression fixes
- Remove duplicated Garmin fields from storage; decode display-only fields
(activity name/type, lap duration/HR, structured workout raw JSON) from
RawJSON at API-response time instead of storing redundant columns
- Add a fully configurable chart color system (pace/HR main-line colors, 4
effort-kind colors, tint/darken/brighten intensity knobs) under Profile >
Chart colors
- Rename training types and fix their display order (Easy, Long, 60'/30'
Threshold, Tempo, Intervals, MAS Test, Race) everywhere they're listed
- Add an Efficiency Factor progression metric; fix Progression chart axes to
use tight non-zero-based domains, m:ss/km pace formatting, and rounded
ticks instead of raw floating-point labels
- Expose the raw get_workout_by_id() payload in the raw-data viewer
alongside activity/lap/detail JSON; enlarge the modal and shrink array
indentation for readability
- Fix "last sync" reporting a meaningless activity count: record one
combined sync run per manual "Sync now" and count genuinely new
activities instead of re-listing whatever Garmin returned for the queried
window
- Let a Review Queue activity be manually cleared back to Unclassified, and
make "Reset all" available even while disconnected from Garmin
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 06:33:47 +02:00
|
|
|
func TestMetricValue_EfficiencyFactor(t *testing.T) {
|
|
|
|
|
speed, hr := 3.0, 150.0
|
|
|
|
|
v, ok := metricValue("efficiency_factor", store.Activity{AvgSpeedMps: &speed, AvgHR: &hr})
|
|
|
|
|
if !ok {
|
|
|
|
|
t.Fatal("expected efficiency_factor to be computable")
|
|
|
|
|
}
|
|
|
|
|
if v != 20 {
|
|
|
|
|
t.Errorf("efficiency_factor = %v, want 20 (3.0/150*1000)", v)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if _, ok := metricValue("efficiency_factor", store.Activity{AvgSpeedMps: &speed}); ok {
|
|
|
|
|
t.Error("expected efficiency_factor to be unavailable without AvgHR")
|
|
|
|
|
}
|
|
|
|
|
zero := 0.0
|
|
|
|
|
if _, ok := metricValue("efficiency_factor", store.Activity{AvgSpeedMps: &speed, AvgHR: &zero}); ok {
|
|
|
|
|
t.Error("expected efficiency_factor to be unavailable with AvgHR=0")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-17 18:33:06 +02:00
|
|
|
func itoa(v int64) string {
|
|
|
|
|
return strconv.FormatInt(v, 10)
|
|
|
|
|
}
|
2026-07-17 19:03:29 +02:00
|
|
|
|
|
|
|
|
func TestProfile_GetDefaultsThenUpdate(t *testing.T) {
|
|
|
|
|
s, _ := newTestServer(t)
|
|
|
|
|
router := s.Router()
|
|
|
|
|
|
|
|
|
|
rec := doJSON(t, router, http.MethodGet, "/api/profile", nil)
|
|
|
|
|
if rec.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("get status = %d, body = %s", rec.Code, rec.Body.String())
|
|
|
|
|
}
|
|
|
|
|
var got store.Profile
|
|
|
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
|
|
|
|
t.Fatalf("unmarshal: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if got.RollingWindowDays != 90 {
|
|
|
|
|
t.Fatalf("RollingWindowDays = %d, want 90", got.RollingWindowDays)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
got.GarminEmail = "runner@example.com"
|
|
|
|
|
got.GarminPassword = "hunter2"
|
|
|
|
|
got.RollingWindowDays = 120
|
|
|
|
|
rec = doJSON(t, router, http.MethodPut, "/api/profile", got)
|
|
|
|
|
if rec.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("put status = %d, body = %s", rec.Code, rec.Body.String())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
rec = doJSON(t, router, http.MethodGet, "/api/profile", nil)
|
|
|
|
|
var updated store.Profile
|
|
|
|
|
json.Unmarshal(rec.Body.Bytes(), &updated)
|
|
|
|
|
if updated.GarminEmail != "runner@example.com" || updated.RollingWindowDays != 120 {
|
|
|
|
|
t.Fatalf("updated = %+v, want new email/window", updated)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestProfile_RejectsInvalidHRZones(t *testing.T) {
|
|
|
|
|
s, _ := newTestServer(t)
|
|
|
|
|
router := s.Router()
|
|
|
|
|
|
|
|
|
|
rec := doJSON(t, router, http.MethodGet, "/api/profile", nil)
|
|
|
|
|
var p store.Profile
|
|
|
|
|
json.Unmarshal(rec.Body.Bytes(), &p)
|
|
|
|
|
|
|
|
|
|
maxHR, restingHR := 100.0, 150.0 // resting > max: invalid
|
|
|
|
|
p.MaxHeartRate = &maxHR
|
|
|
|
|
p.RestingHeartRate = &restingHR
|
|
|
|
|
|
|
|
|
|
rec = doJSON(t, router, http.MethodPut, "/api/profile", p)
|
|
|
|
|
if rec.Code != http.StatusBadRequest {
|
|
|
|
|
t.Fatalf("status = %d, want 400, body = %s", rec.Code, rec.Body.String())
|
|
|
|
|
}
|
|
|
|
|
}
|