initial version

This commit is contained in:
2025-05-01 00:46:36 +02:00
parent c40d790bd0
commit ab36746c6b
15 changed files with 40107 additions and 0 deletions

160
.gitignore vendored Normal file
View File

@@ -0,0 +1,160 @@
# Created by https://www.toptal.com/developers/gitignore/api/goland+all,go,linux,windows
# Edit at https://www.toptal.com/developers/gitignore?templates=goland+all,go,linux,windows
### Go ###
# If you prefer the allow list template instead of the deny list, see community template:
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
#
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# app specific
bicyclePlanner
daily/
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Dependency directories (remove the comment below to include it)
# vendor/
# Go workspace file
go.work
### GoLand+all ###
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
# User-specific stuff
.idea/**/workspace.xml
.idea/**/tasks.xml
.idea/**/usage.statistics.xml
.idea/**/dictionaries
.idea/**/shelf
# AWS User-specific
.idea/**/aws.xml
# Generated files
.idea/**/contentModel.xml
# Sensitive or high-churn files
.idea/**/dataSources/
.idea/**/dataSources.ids
.idea/**/dataSources.local.xml
.idea/**/sqlDataSources.xml
.idea/**/dynamic.xml
.idea/**/uiDesigner.xml
.idea/**/dbnavigator.xml
# Gradle
.idea/**/gradle.xml
.idea/**/libraries
# Gradle and Maven with auto-import
# When using Gradle or Maven with auto-import, you should exclude module files,
# since they will be recreated, and may cause churn. Uncomment if using
# auto-import.
# .idea/artifacts
# .idea/compiler.xml
# .idea/jarRepositories.xml
# .idea/modules.xml
# .idea/*.iml
# .idea/modules
# *.iml
# *.ipr
# CMake
cmake-build-*/
# Mongo Explorer plugin
.idea/**/mongoSettings.xml
# File-based project format
*.iws
# IntelliJ
out/
# mpeltonen/sbt-idea plugin
.idea_modules/
# JIRA plugin
atlassian-ide-plugin.xml
# Cursive Clojure plugin
.idea/replstate.xml
# SonarLint plugin
.idea/sonarlint/
# Crashlytics plugin (for Android Studio and IntelliJ)
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
# Editor-based Rest Client
.idea/httpRequests
# Android studio 3.1+ serialized cache file
.idea/caches/build_file_checksums.ser
### GoLand+all Patch ###
# Ignore everything but code style settings and run configurations
# that are supposed to be shared within teams.
.idea/*
!.idea/codeStyles
!.idea/runConfigurations
### Linux ###
*~
# temporary files which can be created if a process still has a handle open of a deleted file
.fuse_hidden*
# KDE directory preferences
.directory
# Linux trash folder which might appear on any partition or disk
.Trash-*
# .nfs files are created when an open file is removed but is still being accessed
.nfs*
### Windows ###
# Windows thumbnail cache files
Thumbs.db
Thumbs.db:encryptable
ehthumbs.db
ehthumbs_vista.db
# Dump file
*.stackdump
# Folder config file
[Dd]esktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Windows Installer files
*.cab
*.msi
*.msix
*.msm
*.msp
# Windows shortcuts
*.lnk
# End of https://www.toptal.com/developers/gitignore/api/goland+all,go,linux,windows

17
go.mod Normal file
View File

@@ -0,0 +1,17 @@
module bicyclePlanner
go 1.24
require github.com/tkrajina/gpxgo v1.4.0
require (
github.com/fatih/color v1.18.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.9 // indirect
github.com/olekukonko/tablewriter v0.0.5 // indirect
github.com/rs/zerolog v1.34.0 // indirect
golang.org/x/net v0.0.0-20210614182718-04defd469f4e // indirect
golang.org/x/sys v0.25.0 // indirect
golang.org/x/text v0.3.6 // indirect
)

45
go.sum Normal file
View File

@@ -0,0 +1,45 @@
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0=
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/tkrajina/gpxgo v1.4.0 h1:cSD5uSwy3VZuNFieTEZLyRnuIwhonQEkGPkPGW4XNag=
github.com/tkrajina/gpxgo v1.4.0/go.mod h1:BXSMfUAvKiEhMEXAFM2NvNsbjsSvp394mOvdcNjettg=
golang.org/x/net v0.0.0-20210614182718-04defd469f4e h1:XpT3nA5TvE525Ne3hInMh6+GETgn27Zfm9dxsThnX2Q=
golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

2996
main.go Normal file

File diff suppressed because it is too large Load Diff

592
main_test.go Normal file
View File

@@ -0,0 +1,592 @@
package main
import (
"github.com/tkrajina/gpxgo/gpx"
"math"
"testing"
)
func TestParseMultiStops(t *testing.T) {
tests := []struct {
name string
input string
expected []MultiStop
}{
{
name: "Empty input",
input: "",
expected: []MultiStop{},
},
{
name: "Single stop",
input: "59.3293,18.0686:1",
expected: []MultiStop{
{Lat: 59.3293, Lon: 18.0686, Nights: 1},
},
},
{
name: "Multiple stops",
input: "59.3293,18.0686:1;60.1282,18.6435:2",
expected: []MultiStop{
{Lat: 59.3293, Lon: 18.0686, Nights: 1},
{Lat: 60.1282, Lon: 18.6435, Nights: 2},
},
},
{
name: "Invalid format",
input: "invalid",
expected: []MultiStop{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := parseMultiStops(tt.input)
if len(result) != len(tt.expected) {
t.Errorf("Expected %d stops, got %d", len(tt.expected), len(result))
return
}
for i, stop := range result {
if stop.Lat != tt.expected[i].Lat || stop.Lon != tt.expected[i].Lon || stop.Nights != tt.expected[i].Nights {
t.Errorf("Stop %d mismatch: expected %+v, got %+v", i, tt.expected[i], stop)
}
}
})
}
}
func TestHaversine(t *testing.T) {
tests := []struct {
name string
lat1 float64
lon1 float64
lat2 float64
lon2 float64
expected float64
}{
{
name: "Same point",
lat1: 59.3293,
lon1: 18.0686,
lat2: 59.3293,
lon2: 18.0686,
expected: 0,
},
{
name: "Stockholm to Uppsala",
lat1: 59.3293,
lon1: 18.0686,
lat2: 59.8586,
lon2: 17.6389,
expected: 63.63, // Approximate distance in km
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := haversine(tt.lat1, tt.lon1, tt.lat2, tt.lon2)
// Allow for small floating point differences
if math.Abs(result-tt.expected) > 0.5 {
t.Errorf("Expected distance of %.2f km, got %.2f km", tt.expected, result)
}
})
}
}
func TestNormalizeCutIndexes(t *testing.T) {
tests := []struct {
name string
cutIndexes []int
days int
totalPoints int
expected []int
}{
{
name: "No cuts",
cutIndexes: []int{},
days: 5,
totalPoints: 100,
expected: []int{},
},
{
name: "Fewer cuts than days",
cutIndexes: []int{25, 50, 75},
days: 5,
totalPoints: 100,
expected: []int{25, 50, 75},
},
{
name: "More cuts than days",
cutIndexes: []int{20, 40, 60, 80, 99},
days: 3,
totalPoints: 100,
expected: []int{20, 40, 99},
},
{
name: "Duplicate cuts (rest days)",
cutIndexes: []int{25, 25, 50, 75},
days: 5,
totalPoints: 100,
expected: []int{25, 25, 50, 75},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := normalizeCutIndexes(tt.cutIndexes, tt.days, tt.totalPoints)
if len(result) != len(tt.expected) {
t.Errorf("Expected %d cuts, got %d", len(tt.expected), len(result))
return
}
for i, cut := range result {
if cut != tt.expected[i] {
t.Errorf("Cut %d mismatch: expected %d, got %d", i, tt.expected[i], cut)
}
}
})
}
}
func TestCreateSegment(t *testing.T) {
// Create test points
points := []TrackPoint{
{Index: 0, Distance: 0, Elevation: 0},
{Index: 1, Distance: 10, Elevation: 100},
{Index: 2, Distance: 20, Elevation: 200},
{Index: 3, Distance: 30, Elevation: 300},
{Index: 4, Distance: 40, Elevation: 400},
{Index: 5, Distance: 50, Elevation: 500},
}
tests := []struct {
name string
start int
end int
cutIndexes []int
segmentIndex int
expectedLen int
}{
{
name: "Normal segment",
start: 0,
end: 3,
cutIndexes: []int{3, 5},
segmentIndex: 0,
expectedLen: 3, // Points 0, 1, 2
},
{
name: "Forced stop (end <= start)",
start: 3,
end: 3,
cutIndexes: []int{3, 5},
segmentIndex: 0,
expectedLen: 2, // Points 3, 4 (halfway to the next cut)
},
{
name: "Last segment",
start: 3,
end: 5,
cutIndexes: []int{3, 5},
segmentIndex: 1,
expectedLen: 2, // Points 3, 4
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := createSegment(points, tt.start, tt.end, tt.cutIndexes, tt.segmentIndex)
if len(result) != tt.expectedLen {
t.Errorf("Expected segment length %d, got %d", tt.expectedLen, len(result))
}
})
}
}
func TestCreateDaySegment(t *testing.T) {
// Create test points
points := []TrackPoint{
{Index: 0, Distance: 0, Elevation: 0},
{Index: 1, Distance: 10, Elevation: 100},
{Index: 2, Distance: 20, Elevation: 200},
}
dayNumber := 1
elevFactor := 4.0
forestRadius := 3
resupplyRadius := 500
// This test will not query for forest or resupply points
// since that would require network access. We're just testing the
// calculation of distance, elevation, and effort.
segment := createDaySegment(points, dayNumber, elevFactor, forestRadius, resupplyRadius, false)
if segment.DayNumber != dayNumber {
t.Errorf("Expected day number %d, got %d", dayNumber, segment.DayNumber)
}
expectedDist := 20.0 // Last point distance - first point distance
if math.Abs(segment.Distance-expectedDist) > 0.1 {
t.Errorf("Expected distance %.1f, got %.1f", expectedDist, segment.Distance)
}
expectedElev := 200.0 // Last point elevation - first point elevation
if math.Abs(segment.Elevation-expectedElev) > 0.1 {
t.Errorf("Expected elevation %.1f, got %.1f", expectedElev, segment.Elevation)
}
expectedEffort := expectedDist + (expectedElev / 100.0 * elevFactor)
if math.Abs(segment.Effort-expectedEffort) > 0.1 {
t.Errorf("Expected effort %.1f, got %.1f", expectedEffort, segment.Effort)
}
}
func TestCalculateCutIndexes(t *testing.T) {
// Create test route data
points := []TrackPoint{
{Index: 0, Distance: 0, Elevation: 0},
{Index: 1, Distance: 25, Elevation: 250}, // Effort: 25 + (250/100*4) = 35
{Index: 2, Distance: 50, Elevation: 500}, // Effort: 50 + (500/100*4) = 70
{Index: 3, Distance: 75, Elevation: 750}, // Effort: 75 + (750/100*4) = 105
{Index: 4, Distance: 100, Elevation: 1000}, // Effort: 100 + (1000/100*4) = 140
}
routeData := RouteData{
Points: points,
TotalDist: 100,
TotalElev: 1000,
TotalEffort: 140,
}
tests := []struct {
name string
days int
elevFactor float64
multiStops []MultiStop
expected []int
}{
{
name: "2 days, no stops",
days: 2,
elevFactor: 4.0,
multiStops: []MultiStop{},
expected: []int{2, 4}, // Cut at index 2 (effort 70) and 4 (end)
},
{
name: "3 days, no stops",
days: 3,
elevFactor: 4.0,
multiStops: []MultiStop{},
expected: []int{1, 3, 4}, // Cut at index 1, 3, and 4 (end)
},
{
name: "3 days, with one multi-stop",
days: 3,
elevFactor: 4.0,
multiStops: []MultiStop{
{Lat: 0, Lon: 0, Nights: 1}, // This will match point 0
},
expected: []int{0, 1, 4}, // Cut at index 0 (forced stop), 1, and 4 (end)
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := calculateCutIndexes(routeData, tt.days, tt.elevFactor, tt.multiStops)
if len(result) != len(tt.expected) {
t.Errorf("Expected %d cuts, got %d", len(tt.expected), len(result))
return
}
for i, cut := range result {
if cut != tt.expected[i] {
t.Errorf("Cut %d mismatch: expected %d, got %d", i, tt.expected[i], cut)
}
}
})
}
}
// New tests for refactored functions
func TestFindForcedStopIndexes(t *testing.T) {
// Create test points with latitude and longitude
points := []TrackPoint{
{Index: 0, Point: gpx.GPXPoint{Point: gpx.Point{Latitude: 59.3293, Longitude: 18.0686}}},
{Index: 1, Point: gpx.GPXPoint{Point: gpx.Point{Latitude: 59.8586, Longitude: 17.6389}}},
{Index: 2, Point: gpx.GPXPoint{Point: gpx.Point{Latitude: 60.1282, Longitude: 18.6435}}},
}
tests := []struct {
name string
points []TrackPoint
multiStops []MultiStop
expected []int
}{
{
name: "No stops",
points: points,
multiStops: []MultiStop{},
expected: []int{},
},
{
name: "One stop matching first point",
points: points,
multiStops: []MultiStop{
{Lat: 59.3293, Lon: 18.0686, Nights: 1}, // Matches point 0
},
expected: []int{0},
},
{
name: "One stop with multiple nights",
points: points,
multiStops: []MultiStop{
{Lat: 59.8586, Lon: 17.6389, Nights: 2}, // Matches point 1
},
expected: []int{1, 1}, // Duplicated for 2 nights
},
{
name: "Multiple stops",
points: points,
multiStops: []MultiStop{
{Lat: 59.3293, Lon: 18.0686, Nights: 1}, // Matches point 0
{Lat: 60.1282, Lon: 18.6435, Nights: 1}, // Matches point 2
},
expected: []int{0, 2},
},
{
name: "Stop too far from any point",
points: points,
multiStops: []MultiStop{
{Lat: 55.0, Lon: 15.0, Nights: 1}, // Far from all points
},
expected: []int{}, // No match within 2km
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := findForcedStopIndexes(tt.points, tt.multiStops)
if len(result) != len(tt.expected) {
t.Errorf("Expected %d forced stops, got %d", len(tt.expected), len(result))
return
}
for i, idx := range result {
if idx != tt.expected[i] {
t.Errorf("Forced stop %d mismatch: expected %d, got %d", i, tt.expected[i], idx)
}
}
})
}
}
func TestHandleTooManyForcedStops(t *testing.T) {
tests := []struct {
name string
forcedStopIndexes []int
days int
lastPointIndex int
totalPoints int
expected []int
}{
{
name: "Fewer forced stops than days",
forcedStopIndexes: []int{25, 50},
days: 4,
lastPointIndex: 99,
totalPoints: 100,
expected: []int{25, 50, 99},
},
{
name: "More forced stops than days",
forcedStopIndexes: []int{20, 40, 60, 80},
days: 3,
lastPointIndex: 99,
totalPoints: 100,
expected: []int{20, 40, 99}, // Only first (days-1) forced stops are kept
},
{
name: "Equal forced stops and days",
forcedStopIndexes: []int{33, 66},
days: 3,
lastPointIndex: 99,
totalPoints: 100,
expected: []int{33, 66, 99},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := handleTooManyForcedStops(tt.forcedStopIndexes, tt.days, tt.lastPointIndex, tt.totalPoints)
if len(result) != len(tt.expected) {
t.Errorf("Expected %d cuts, got %d", len(tt.expected), len(result))
return
}
for i, cut := range result {
if cut != tt.expected[i] {
t.Errorf("Cut %d mismatch: expected %d, got %d", i, tt.expected[i], cut)
}
}
})
}
}
func TestCreateSegmentsBetweenStops(t *testing.T) {
// Create test points
points := []TrackPoint{
{Index: 0, Distance: 0, Elevation: 0},
{Index: 1, Distance: 10, Elevation: 100},
{Index: 2, Distance: 20, Elevation: 200},
{Index: 3, Distance: 30, Elevation: 300},
{Index: 4, Distance: 40, Elevation: 400},
}
tests := []struct {
name string
points []TrackPoint
allStops []int
elevFactor float64
expected []struct {
start, end int
effort float64
}
}{
{
name: "No stops",
points: points,
allStops: []int{},
elevFactor: 4.0,
expected: []struct {
start, end int
effort float64
}{},
},
{
name: "One segment",
points: points,
allStops: []int{0, 4},
elevFactor: 4.0,
expected: []struct {
start, end int
effort float64
}{
{0, 4, 56.0}, // Effort: 40 + (400/100*4) = 56
},
},
{
name: "Multiple segments",
points: points,
allStops: []int{0, 2, 4},
elevFactor: 4.0,
expected: []struct {
start, end int
effort float64
}{
{0, 2, 28.0}, // Effort: 20 + (200/100*4) = 28
{2, 4, 42.0}, // Effort: (30-20) + ((300-200)/100*4) + (40-30) + ((400-300)/100*4) = 42
},
},
{
name: "Duplicate stops (rest days)",
points: points,
allStops: []int{0, 2, 2, 4},
elevFactor: 4.0,
expected: []struct {
start, end int
effort float64
}{
{0, 2, 28.0}, // Effort: 20 + (200/100*4) = 28
{2, 4, 42.0}, // Effort: (30-20) + ((300-200)/100*4) + (40-30) + ((400-300)/100*4) = 42
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := createSegmentsBetweenStops(tt.points, tt.allStops, tt.elevFactor)
if len(result) != len(tt.expected) {
t.Errorf("Expected %d segments, got %d", len(tt.expected), len(result))
return
}
for i, seg := range result {
if seg.start != tt.expected[i].start || seg.end != tt.expected[i].end {
t.Errorf("Segment %d bounds mismatch: expected (%d,%d), got (%d,%d)",
i, tt.expected[i].start, tt.expected[i].end, seg.start, seg.end)
}
if math.Abs(seg.effort-tt.expected[i].effort) > 0.1 {
t.Errorf("Segment %d effort mismatch: expected %.1f, got %.1f",
i, tt.expected[i].effort, seg.effort)
}
}
})
}
}
func TestHandleTooManyCuts(t *testing.T) {
tests := []struct {
name string
cutIndexes []int
forcedStopIndexes []int
days int
lastPointIndex int
expectedLen int
}{
{
name: "Fewer cuts than days",
cutIndexes: []int{25, 50},
forcedStopIndexes: []int{},
days: 4,
lastPointIndex: 99,
expectedLen: 4, // Will add cuts to reach days
},
{
name: "More cuts than days",
cutIndexes: []int{20, 40, 60, 80},
forcedStopIndexes: []int{},
days: 3,
lastPointIndex: 99,
expectedLen: 3, // Will reduce to days
},
{
name: "With forced stops",
cutIndexes: []int{20, 40, 60, 80},
forcedStopIndexes: []int{20, 60},
days: 3,
lastPointIndex: 99,
expectedLen: 3, // Will keep forced stops and last point
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := handleTooManyCuts(tt.cutIndexes, tt.forcedStopIndexes, tt.days, tt.lastPointIndex)
if len(result) != tt.expectedLen {
t.Errorf("Expected %d cuts, got %d", tt.expectedLen, len(result))
}
// Check that forced stops are included
for _, forcedIdx := range tt.forcedStopIndexes {
found := false
for _, resultIdx := range result {
if resultIdx == forcedIdx {
found = true
break
}
}
if !found {
t.Errorf("Forced stop %d not found in result", forcedIdx)
}
}
// Check that last point is included
lastPointFound := false
for _, resultIdx := range result {
if resultIdx == tt.lastPointIndex {
lastPointFound = true
break
}
}
if !lastPointFound {
t.Errorf("Last point %d not found in result", tt.lastPointIndex)
}
})
}
}

14
run_all_tests.sh Executable file
View File

@@ -0,0 +1,14 @@
#!/bin/sh
set -x
go test ./...
cd tests || exit 1
for file in *.sh; do
if [ -d "$file" ]; then
continue
fi
./$file
done

View File

@@ -0,0 +1,79 @@
<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="Test">
<trk>
<name>Test Route with Uneven Effort</name>
<trkseg>
<!-- Day 1: Relatively flat -->
<trkpt lat="59.0" lon="18.0"><ele>10</ele></trkpt>
<trkpt lat="59.1" lon="18.1"><ele>20</ele></trkpt>
<trkpt lat="59.2" lon="18.2"><ele>30</ele></trkpt>
<trkpt lat="59.3" lon="18.3"><ele>40</ele></trkpt>
<trkpt lat="59.4" lon="18.4"><ele>50</ele></trkpt>
<trkpt lat="59.5" lon="18.5"><ele>60</ele></trkpt>
<trkpt lat="59.6" lon="18.6"><ele>70</ele></trkpt>
<trkpt lat="59.7" lon="18.7"><ele>80</ele></trkpt>
<trkpt lat="59.8" lon="18.8"><ele>90</ele></trkpt>
<trkpt lat="59.9" lon="18.9"><ele>100</ele></trkpt>
<!-- Day 2: Some hills -->
<trkpt lat="60.0" lon="19.0"><ele>120</ele></trkpt>
<trkpt lat="60.1" lon="19.1"><ele>150</ele></trkpt>
<trkpt lat="60.2" lon="19.2"><ele>180</ele></trkpt>
<trkpt lat="60.3" lon="19.3"><ele>200</ele></trkpt>
<trkpt lat="60.4" lon="19.4"><ele>220</ele></trkpt>
<trkpt lat="60.5" lon="19.5"><ele>240</ele></trkpt>
<trkpt lat="60.6" lon="19.6"><ele>260</ele></trkpt>
<trkpt lat="60.7" lon="19.7"><ele>280</ele></trkpt>
<trkpt lat="60.8" lon="19.8"><ele>300</ele></trkpt>
<trkpt lat="60.9" lon="19.9"><ele>320</ele></trkpt>
<!-- Day 3: Moderate hills -->
<trkpt lat="61.0" lon="20.0"><ele>340</ele></trkpt>
<trkpt lat="61.1" lon="20.1"><ele>360</ele></trkpt>
<trkpt lat="61.2" lon="20.2"><ele>380</ele></trkpt>
<trkpt lat="61.3" lon="20.3"><ele>400</ele></trkpt>
<trkpt lat="61.4" lon="20.4"><ele>420</ele></trkpt>
<trkpt lat="61.5" lon="20.5"><ele>440</ele></trkpt>
<trkpt lat="61.6" lon="20.6"><ele>460</ele></trkpt>
<trkpt lat="61.7" lon="20.7"><ele>480</ele></trkpt>
<trkpt lat="61.8" lon="20.8"><ele>500</ele></trkpt>
<trkpt lat="61.9" lon="20.9"><ele>520</ele></trkpt>
<!-- Day 4: Steeper hills -->
<trkpt lat="62.0" lon="21.0"><ele>550</ele></trkpt>
<trkpt lat="62.1" lon="21.1"><ele>600</ele></trkpt>
<trkpt lat="62.2" lon="21.2"><ele>650</ele></trkpt>
<trkpt lat="62.3" lon="21.3"><ele>700</ele></trkpt>
<trkpt lat="62.4" lon="21.4"><ele>750</ele></trkpt>
<trkpt lat="62.5" lon="21.5"><ele>800</ele></trkpt>
<trkpt lat="62.6" lon="21.6"><ele>850</ele></trkpt>
<trkpt lat="62.7" lon="21.7"><ele>900</ele></trkpt>
<trkpt lat="62.8" lon="21.8"><ele>950</ele></trkpt>
<trkpt lat="62.9" lon="21.9"><ele>1000</ele></trkpt>
<!-- Day 5: Very steep section -->
<trkpt lat="63.0" lon="22.0"><ele>1050</ele></trkpt>
<trkpt lat="63.1" lon="22.1"><ele>1150</ele></trkpt>
<trkpt lat="63.2" lon="22.2"><ele>1250</ele></trkpt>
<trkpt lat="63.3" lon="22.3"><ele>1350</ele></trkpt>
<trkpt lat="63.4" lon="22.4"><ele>1450</ele></trkpt>
<trkpt lat="63.5" lon="22.5"><ele>1550</ele></trkpt>
<trkpt lat="63.6" lon="22.6"><ele>1650</ele></trkpt>
<trkpt lat="63.7" lon="22.7"><ele>1750</ele></trkpt>
<trkpt lat="63.8" lon="22.8"><ele>1850</ele></trkpt>
<trkpt lat="63.9" lon="22.9"><ele>1950</ele></trkpt>
<!-- Day 6: Extremely steep section -->
<trkpt lat="64.0" lon="23.0"><ele>2050</ele></trkpt>
<trkpt lat="64.1" lon="23.1"><ele>2200</ele></trkpt>
<trkpt lat="64.2" lon="23.2"><ele>2350</ele></trkpt>
<trkpt lat="64.3" lon="23.3"><ele>2500</ele></trkpt>
<trkpt lat="64.4" lon="23.4"><ele>2650</ele></trkpt>
<trkpt lat="64.5" lon="23.5"><ele>2800</ele></trkpt>
<trkpt lat="64.6" lon="23.6"><ele>2950</ele></trkpt>
<trkpt lat="64.7" lon="23.7"><ele>3100</ele></trkpt>
<trkpt lat="64.8" lon="23.8"><ele>3250</ele></trkpt>
<trkpt lat="64.9" lon="23.9"><ele>3400</ele></trkpt>
</trkseg>
</trk>
</gpx>

36065
tests/gpx/very_long_route.gpx Normal file

File diff suppressed because it is too large Load Diff

35
tests/test_forced_stop.sh Executable file
View File

@@ -0,0 +1,35 @@
#!/bin/bash
cd "$(dirname "$0")/.."
# Create a test GPX file with a simple route
cat > test_route.gpx << EOF
<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="Test">
<trk>
<name>Test Route</name>
<trkseg>
<trkpt lat="59.0" lon="18.0"><ele>10</ele></trkpt>
<trkpt lat="59.1" lon="18.1"><ele>20</ele></trkpt>
<trkpt lat="59.2" lon="18.2"><ele>30</ele></trkpt>
<trkpt lat="59.3" lon="18.3"><ele>40</ele></trkpt>
<trkpt lat="59.4" lon="18.4"><ele>50</ele></trkpt>
<trkpt lat="59.5" lon="18.5"><ele>60</ele></trkpt>
<trkpt lat="59.6" lon="18.6"><ele>70</ele></trkpt>
<trkpt lat="59.7" lon="18.7"><ele>80</ele></trkpt>
<trkpt lat="59.8" lon="18.8"><ele>90</ele></trkpt>
<trkpt lat="59.9" lon="18.9"><ele>100</ele></trkpt>
</trkseg>
</trk>
</gpx>
EOF
# Run the bicycle planner with a 1-night forced stop
echo "Testing with 1-night forced stop at 59.5,18.5"
go run main.go --input="test_route.gpx" --days=5 --elevFactor=4 --forestRadius=5 --resupplyRadius=5000 --multiStop="59.5,18.5:1"
# The number of segments is now shown in the output
echo "Check the output above for the number of segments created"
# Clean up
rm test_route.gpx

35
tests/test_multi_night_stop.sh Executable file
View File

@@ -0,0 +1,35 @@
#!/bin/bash
cd "$(dirname "$0")/.."
# Create a test GPX file with a simple route
cat > test_route.gpx << EOF
<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="Test">
<trk>
<name>Test Route</name>
<trkseg>
<trkpt lat="59.0" lon="18.0"><ele>10</ele></trkpt>
<trkpt lat="59.1" lon="18.1"><ele>20</ele></trkpt>
<trkpt lat="59.2" lon="18.2"><ele>30</ele></trkpt>
<trkpt lat="59.3" lon="18.3"><ele>40</ele></trkpt>
<trkpt lat="59.4" lon="18.4"><ele>50</ele></trkpt>
<trkpt lat="59.5" lon="18.5"><ele>60</ele></trkpt>
<trkpt lat="59.6" lon="18.6"><ele>70</ele></trkpt>
<trkpt lat="59.7" lon="18.7"><ele>80</ele></trkpt>
<trkpt lat="59.8" lon="18.8"><ele>90</ele></trkpt>
<trkpt lat="59.9" lon="18.9"><ele>100</ele></trkpt>
</trkseg>
</trk>
</gpx>
EOF
# Run the bicycle planner with a 2-night forced stop
echo "Testing with 2-night forced stop at 59.5,18.5"
go run main.go --input="test_route.gpx" --days=6 --elevFactor=4 --forestRadius=5 --resupplyRadius=5000 --multiStop="59.5,18.5:2"
# The number of segments is now shown in the output
echo "Check the output above for the number of segments created"
# Clean up
rm test_route.gpx

37
tests/test_town_avoidance.sh Executable file
View File

@@ -0,0 +1,37 @@
#!/bin/bash
cd "$(dirname "$0")/.."
# Create a test GPX file with a simple route
cat > test_route.gpx << EOF
<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="Test">
<trk>
<name>Test Route</name>
<trkseg>
<trkpt lat="59.0" lon="18.0"><ele>10</ele></trkpt>
<trkpt lat="59.1" lon="18.1"><ele>20</ele></trkpt>
<trkpt lat="59.2" lon="18.2"><ele>30</ele></trkpt>
<trkpt lat="59.3" lon="18.3"><ele>40</ele></trkpt>
<trkpt lat="59.4" lon="18.4"><ele>50</ele></trkpt>
<trkpt lat="59.5" lon="18.5"><ele>60</ele></trkpt>
<trkpt lat="59.6" lon="18.6"><ele>70</ele></trkpt>
<trkpt lat="59.7" lon="18.7"><ele>80</ele></trkpt>
<trkpt lat="59.8" lon="18.8"><ele>90</ele></trkpt>
<trkpt lat="59.9" lon="18.9"><ele>100</ele></trkpt>
</trkseg>
</trk>
</gpx>
EOF
# Run the bicycle planner with debug output to see town avoidance in action
echo "Testing town avoidance feature with performance improvements"
echo "Running first time (no cache)..."
time go run main.go --input="test_route.gpx" --days=5 --elevFactor=4 --forestRadius=5 --resupplyRadius=5000
echo ""
echo "Running second time (with cache)..."
time go run main.go --input="test_route.gpx" --days=5 --elevFactor=4 --forestRadius=5 --resupplyRadius=5000
# Clean up
rm test_route.gpx

6
tests/test_uneven_effort.sh Executable file
View File

@@ -0,0 +1,6 @@
#!/bin/bash
# Test script for uneven effort distribution
echo "Testing uneven effort distribution..."
cd "$(dirname "$0")/.."
go run main.go -input tests/gpx/test_uneven_effort.gpx -days 12 -elevFactor 4.0

View File

@@ -0,0 +1,8 @@
#!/bin/bash
# Test script for uneven effort distribution with forced stops
echo "Testing uneven effort distribution with forced stops..."
cd "$(dirname "$0")/.."
# Run the bicycle planner with the test GPX file and forced stops
go run main.go -input tests/gpx/test_uneven_effort.gpx -days 12 -elevFactor 4.0 -multiStop="60.5,19.5:1;62.5,21.5:2;64.5,23.5:1"

View File

@@ -0,0 +1,9 @@
#!/bin/bash
# Test script for the issue with uneven effort distribution
echo "Testing issue with uneven effort distribution..."
cd "$(dirname "$0")/.."
# Run the bicycle planner with the specified parameters and debug logging
# Filter the output to only show the relevant parts
go run main.go -input tests/gpx/very_long_route.gpx -days 12 -forestRadius 5 -resupplyRadius 5000 -multiStop "59.126854,11.380377:1;59.92199,10.744629:2"

View File

@@ -0,0 +1,9 @@
#!/bin/bash
# Test script for the issue with uneven effort distribution
echo "Testing issue with uneven effort distribution..."
cd "$(dirname "$0")/.."
# Run the bicycle planner with the specified parameters and debug logging
# Filter the output to only show the relevant parts
go run main.go -input tests/gpx/very_long_route.gpx -days 14 -forestRadius 5 -resupplyRadius 5000 -multiStop "59.126854,11.380377:1;59.92199,10.744629:2"