diff --git a/internal/gpx/gpx.go b/internal/gpx/gpx.go
index 3ffebfd..5ebbba7 100644
--- a/internal/gpx/gpx.go
+++ b/internal/gpx/gpx.go
@@ -125,26 +125,6 @@ func (t Track) LengthKm() float64 {
return sum
}
-// BBox 返回 [minLon, minLat, maxLon, maxLat]。
-func (t Track) BBox() [4]float64 {
- b := [4]float64{180, 90, -180, -90}
- for _, p := range t.Points {
- if p.Lon < b[0] {
- b[0] = p.Lon
- }
- if p.Lat < b[1] {
- b[1] = p.Lat
- }
- if p.Lon > b[2] {
- b[2] = p.Lon
- }
- if p.Lat > b[3] {
- b[3] = p.Lat
- }
- }
- return b
-}
-
func haversine(a, b Point) float64 {
const r = 6371.0
dLat := (b.Lat - a.Lat) * math.Pi / 180
diff --git a/internal/gpx/gpx_test.go b/internal/gpx/gpx_test.go
new file mode 100644
index 0000000..d1b4071
--- /dev/null
+++ b/internal/gpx/gpx_test.go
@@ -0,0 +1,166 @@
+package gpx
+
+import (
+ "math"
+ "strings"
+ "testing"
+ "time"
+)
+
+const trkGPX = `
+
+ 元数据名
+
+ 轨迹名
+
+ 12.5
+ 13.5
+
+
+`
+
+func TestParseTrk(t *testing.T) {
+ ts, err := Parse([]byte(trkGPX))
+ if err != nil {
+ t.Fatalf("解析失败: %v", err)
+ }
+ if len(ts) != 1 {
+ t.Fatalf("应得 1 条轨迹,实际 %d", len(ts))
+ }
+ tr := ts[0]
+ if tr.Name != "轨迹名" {
+ t.Errorf("trk 有 name 时用它,实际 %q", tr.Name)
+ }
+ if len(tr.Points) != 2 {
+ t.Fatalf("应得 2 个点,实际 %d", len(tr.Points))
+ }
+ if tr.Points[0].Ele != 12.5 {
+ t.Errorf("trkpt 的高程该带出来,实际 %v", tr.Points[0].Ele)
+ }
+ want := time.Date(2026, 8, 13, 7, 0, 0, 0, time.UTC)
+ if !tr.Points[0].T.Equal(want) {
+ t.Errorf("时间解析错: %v", tr.Points[0].T)
+ }
+}
+
+// trk 没写 name 时退回 metadata>name——否则整条轨迹会没名字。
+func TestParseTrackNameFallsBackToMetadata(t *testing.T) {
+ src := strings.Replace(trkGPX, "轨迹名", "", 1)
+ ts, err := Parse([]byte(src))
+ if err != nil {
+ t.Fatalf("解析失败: %v", err)
+ }
+ if ts[0].Name != "元数据名" {
+ t.Errorf("应退回 metadata 名字,实际 %q", ts[0].Name)
+ }
+}
+
+// 没有 trk 时退回 rte/rtept。
+func TestParseFallsBackToRoute(t *testing.T) {
+ src := `
+路线
+ 回家
+ 9
+ 11
+ `
+ ts, err := Parse([]byte(src))
+ if err != nil {
+ t.Fatalf("解析失败: %v", err)
+ }
+ if len(ts) != 1 || ts[0].Name != "回家" || len(ts[0].Points) != 2 {
+ t.Fatalf("route 应解析成一条轨迹: %+v", ts)
+ }
+ // 与 trkpt 分支不一致:rtept 的高程没被带出来。当前 Point.Ele 全仓无人消费,
+ // 所以不影响功能;这条断言是把现状钉住,将来若决定统一,改这里会立刻提醒。
+ if ts[0].Points[0].Ele != 0 {
+ t.Errorf("rte 分支当前不带高程,实际 %v(如已统一请更新本用例)", ts[0].Points[0].Ele)
+ }
+}
+
+// (0,0) 是 GPX 里常见的空点,必须丢掉,否则「几内亚湾」会被算进里程。
+func TestParseSkipsNullIsland(t *testing.T) {
+ src := `
+
+
+
+
+`
+ ts, err := Parse([]byte(src))
+ if err != nil {
+ t.Fatalf("解析失败: %v", err)
+ }
+ if len(ts[0].Points) != 2 {
+ t.Fatalf("(0,0) 该被丢掉,实际剩 %d 个点: %+v", len(ts[0].Points), ts[0].Points)
+ }
+}
+
+// 只有一个点算不出里程,整条丢掉;全丢光就报错,别让调用方以为成功。
+func TestParseDropsSinglePointTrack(t *testing.T) {
+ src := `
+
+
+`
+ if ts, err := Parse([]byte(src)); err == nil {
+ t.Fatalf("单点轨迹应当报错,实际返回 %+v", ts)
+ }
+}
+
+func TestParseErrors(t *testing.T) {
+ if _, err := Parse([]byte("这不是 XML")); err == nil ||
+ !strings.Contains(err.Error(), "GPX 解析失败") {
+ t.Errorf("非法 XML 要报「GPX 解析失败」,实际 %v", err)
+ }
+ empty := `x`
+ _, err := Parse([]byte(empty))
+ if err == nil || !strings.Contains(err.Error(), "没有找到轨迹点") {
+ t.Errorf("没有轨迹点要明确报出来,实际 %v", err)
+ }
+}
+
+// 时间格式不对时不该整个失败,只是那个点没有时间。
+func TestParseToleratesBadTime(t *testing.T) {
+ src := `
+
+
+
+`
+ ts, err := Parse([]byte(src))
+ if err != nil {
+ t.Fatalf("解析失败: %v", err)
+ }
+ if !ts[0].Time.IsZero() {
+ t.Errorf("时间解析不出来时应为零值,实际 %v", ts[0].Time)
+ }
+}
+
+// 对跖点附近 h 会因浮点误差略大于 1(实测最多到 1+2^-52)。geo.Haversine 用
+// math.Min(1, …) 夹了一道,gpx 这份没有——但那个夹子在这里其实够不着:sqrt(1+2^-52)
+// 会回舍成正好 1.0,asin(1.0)=π/2,出不了 NaN。这条用例把「对跖点不出 NaN」钉住,
+// 将来若有人换公式或去掉开方,会立刻红。
+func TestLengthKmAntipodalIsFinite(t *testing.T) {
+ for _, c := range [][4]float64{
+ {45, 0, -45, 180},
+ {0, 0, 0, 180},
+ {30.5, 10.25, -30.5, -169.75},
+ } {
+ tr := Track{Points: []Point{{Lat: c[0], Lon: c[1]}, {Lat: c[2], Lon: c[3]}}}
+ if got := tr.LengthKm(); math.IsNaN(got) {
+ t.Errorf("对跖点 %v 算出了 NaN", c)
+ }
+ }
+}
+
+func TestLengthKm(t *testing.T) {
+ // 纬度差 1 度 ≈ 6371*π/180 = 111.19 km
+ tr := Track{Points: []Point{{Lat: 30, Lon: 120}, {Lat: 31, Lon: 120}}}
+ got := tr.LengthKm()
+ if math.Abs(got-111.195) > 0.01 {
+ t.Errorf("1 度纬度该约 111.19 km,实际 %.4f", got)
+ }
+ if n := (Track{Points: []Point{{Lat: 30, Lon: 120}}}).LengthKm(); n != 0 {
+ t.Errorf("单点里程该为 0,实际 %v", n)
+ }
+ if n := (Track{}).LengthKm(); n != 0 {
+ t.Errorf("空轨迹里程该为 0,实际 %v", n)
+ }
+}
diff --git a/internal/rond/diff.go b/internal/rond/diff.go
index 43c3e8f..347fec9 100644
--- a/internal/rond/diff.go
+++ b/internal/rond/diff.go
@@ -398,8 +398,6 @@ type Counts struct {
Added, Removed, Changed, Same int
}
-func (c Counts) Total() int { return c.Added + c.Removed + c.Changed + c.Same }
-
func VisitCounts(d VisitDiff) Counts {
return Counts{len(d.Added), len(d.Removed), len(d.Changed), d.Same}
}
diff --git a/internal/rond/merge_clash_test.go b/internal/rond/merge_clash_test.go
new file mode 100644
index 0000000..f819378
--- /dev/null
+++ b/internal/rond/merge_clash_test.go
@@ -0,0 +1,85 @@
+package rond
+
+import "testing"
+
+// 地点/活动冲突此前一条用例都没有:只造过「新增」,所以 placePick / activityPick
+// 与 UpdPlaces / UpdActs 分支始终没被执行过。差异页线上确实会提交这两类选择。
+func TestBuildMergePlanPlaceClash(t *testing.T) {
+ keyA := PlaceCoordKey(30.123456, 120.654321)
+ keyB := PlaceCoordKey(31.000001, 121.000002)
+ keyC := PlaceCoordKey(32.000003, 122.000004)
+ pd := PlaceDiff{Changed: []PlaceConflict{
+ {Backup: Location{SrcPK: 11, Name: "备份名A"}, DB: StoredPlace{SrcPK: 1, Key: keyA, Name: "站内名A"}},
+ {Backup: Location{SrcPK: 12, Name: "备份名B"}, DB: StoredPlace{SrcPK: 2, Key: keyB, Name: "站内名B"}},
+ {Backup: Location{SrcPK: 13, Name: "备份名C"}, DB: StoredPlace{SrcPK: 3, Key: keyC, Name: "站内名C"}},
+ }}
+ p := BuildMergePlan(VisitDiff{}, pd, ActivityDiff{}, Choices{
+ PlaceClash: map[string]string{keyA: PickBackup, keyB: PickDB},
+ })
+ if len(p.UpdPlaces) != 1 {
+ t.Fatalf("只有选了「用实机」的地点该更新: %+v", p.UpdPlaces)
+ }
+ // 键必须是库里的坐标键:备份侧那条没有 Key 字段,拿它当键会让处理函数找不到行
+ if p.UpdPlaces[0].CoordKey != keyA || p.UpdPlaces[0].L.Name != "备份名A" {
+ t.Fatalf("应当用库里的坐标键 + 备份的值: %+v", p.UpdPlaces[0])
+ }
+ if len(p.UpdVisits)+len(p.AddVisits)+len(p.UpdActs)+len(p.AddActs) != 0 {
+ t.Fatal("没勾新增就不该带出到访/活动改动")
+ }
+}
+
+func TestBuildMergePlanActivityClash(t *testing.T) {
+ ad := ActivityDiff{Changed: []ActivityConflict{
+ {Backup: Activity{SrcPK: 21, Name: "散步"}, DB: StoredActivity{SrcPK: 3, Name: "走路", UUID: uuidC}},
+ {Backup: Activity{SrcPK: 22, Name: "跑步"}, DB: StoredActivity{SrcPK: 4, Name: "跑步"}},
+ {Backup: Activity{SrcPK: 23, Name: "游泳"}, DB: StoredActivity{SrcPK: 5, Name: "游泳", UUID: uuidA}},
+ }}
+ p := BuildMergePlan(VisitDiff{}, PlaceDiff{}, ad, Choices{
+ ActClash: map[string]string{uuidC: PickBackup, "n:跑步": PickSkip},
+ })
+ if len(p.UpdActs) != 1 {
+ t.Fatalf("只有选了「用实机」的活动该更新: %+v", p.UpdActs)
+ }
+ // 改名也能对上:键取 UUID 而不是名称;没 UUID 的才退回 "n:"+名称
+ if p.UpdActs[0].Key != uuidC || p.UpdActs[0].A.Name != "散步" {
+ t.Fatalf("有 UUID 的活动应按 UUID 对上: %+v", p.UpdActs[0])
+ }
+}
+
+// 删除是逐条勾选的事:没勾的「仅站内有」绝不顺手删掉,勾了的才进计划。
+func TestBuildMergePlanDeletesOnlyWhenChecked(t *testing.T) {
+ vd := VisitDiff{Removed: []StoredVisit{{SrcPK: 51}, {SrcPK: 52}}}
+ pd := PlaceDiff{Removed: []StoredPlace{{SrcPK: 31}, {SrcPK: 32}}}
+ ad := ActivityDiff{Removed: []StoredActivity{{SrcPK: 41}, {SrcPK: 42}}}
+ p := BuildMergePlan(vd, pd, ad, Choices{
+ DelVisits: map[int]bool{52: true},
+ DelPlaces: map[int]bool{31: true},
+ DelActs: map[int]bool{42: true},
+ })
+ if len(p.DelVisits) != 1 || p.DelVisits[0].SrcPK != 52 {
+ t.Fatalf("只该删勾了的那条到访: %+v", p.DelVisits)
+ }
+ if len(p.DelPlaces) != 1 || p.DelPlaces[0].SrcPK != 31 {
+ t.Fatalf("只该删勾了的那个地点: %+v", p.DelPlaces)
+ }
+ if len(p.DelActs) != 1 || p.DelActs[0].SrcPK != 42 {
+ t.Fatalf("只该删勾了的那个活动: %+v", p.DelActs)
+ }
+}
+
+func TestAppendMissingDedupesBySrcPK(t *testing.T) {
+ places := appendMissingPlaces(
+ []Location{{SrcPK: 1, Name: "甲"}},
+ []Location{{SrcPK: 1, Name: "甲"}, {SrcPK: 2, Name: "乙"}},
+ )
+ if len(places) != 2 || places[1].SrcPK != 2 {
+ t.Fatalf("同 src_pk 不该重复加入: %+v", places)
+ }
+ acts := appendMissingActs(
+ []Activity{{SrcPK: 7, Name: "通勤"}},
+ []Activity{{SrcPK: 7, Name: "通勤"}, {SrcPK: 8, Name: "散步"}},
+ )
+ if len(acts) != 2 || acts[1].SrcPK != 8 {
+ t.Fatalf("同 src_pk 不该重复加入: %+v", acts)
+ }
+}