package rond import ( "testing" "time" ) func tp(s string) *time.Time { t, err := time.Parse("2006-01-02 15:04", s) if err != nil { panic(err) } return &t } func ip(v int) *int { return &v } // 两组编号刻意完全不同:rond 每次导出都会重排 Z_PK,对齐只能靠 UUID。 const ( uuidA = "11111111111111111111111111111111" uuidB = "22222222222222222222222222222222" uuidC = "33333333333333333333333333333333" ) func sampleBackup() *Backup { return &Backup{ Visits: []Visit{ {SrcPK: 555, Arrival: tp("2026-09-01 10:00"), Departure: tp("2026-09-01 11:00"), LocationSrcPK: ip(3), ActivitySrcPK: ip(7)}, {SrcPK: 556, Arrival: tp("2026-09-02 10:00"), LocationSrcPK: ip(3)}, }, Locations: []Location{ {SrcPK: 3, Name: "家", Lat: 24.1, Lon: 109.1, City: "柳州市"}, {SrcPK: 4, Name: "公司", Lat: 24.2, Lon: 109.2, City: "柳州市"}, }, Activities: []Activity{ {SrcPK: 7, Name: "工作"}, {SrcPK: 8, Name: "散步"}, }, VisitUUID: map[int]string{555: uuidA, 556: uuidB}, ActivityUUID: map[int]string{7: uuidC}, } } func TestDiffVisitsAlignsByUUIDNotPK(t *testing.T) { stored := []StoredVisit{ {SrcPK: 1, UUID: uuidA, Arrival: tp("2026-09-01 10:00"), Departure: tp("2026-09-01 11:00"), PlaceKey: PlaceCoordKey(24.1, 109.1), ActivityName: "工作"}, {SrcPK: 2, UUID: uuidB, Arrival: tp("2026-09-02 10:00"), PlaceKey: PlaceCoordKey(24.1, 109.1)}, } d := DiffVisits(stored, sampleBackup()) if len(d.Added) != 0 || len(d.Removed) != 0 || len(d.Changed) != 0 { t.Fatalf("两边一致时不该有差异: +%d -%d ~%d", len(d.Added), len(d.Removed), len(d.Changed)) } if d.Same != 2 { t.Fatalf("应配上 2 条,实际 %d", d.Same) } } func TestDiffVisitsFieldChanges(t *testing.T) { stored := []StoredVisit{{ SrcPK: 1, UUID: uuidA, Arrival: tp("2026-09-01 09:00"), Departure: tp("2026-09-01 11:00"), PlaceKey: PlaceCoordKey(24.1, 109.1), ActivityName: "工作", Remark: "站点备注", }} d := DiffVisits(stored, sampleBackup()) if len(d.Changed) != 1 { t.Fatalf("到达时间不同应算冲突,实际 %d 条", len(d.Changed)) } f := d.Changed[0].Fields if len(f) != 2 { t.Fatalf("应报出到达时间与备注两项,实际 %v", f) } if f[0].Label != "到达时间" || f[1].Label != "备注" { t.Fatalf("字段名不对: %v", f) } if f[1].From != "站点备注" || f[1].To != "" { t.Fatalf("方向搞反了(From 应是库里的值): %+v", f[1]) } } // 站点新建的到访没有 UUID,而且它的 src_pk 很可能与真机新行的编号撞上—— // 绝不能因为编号相同就当成同一条。 func TestDiffVisitsIgnoresPKCollision(t *testing.T) { stored := []StoredVisit{{ SrcPK: 555, UUID: "", Arrival: tp("2026-08-01 08:00"), UserAdded: true, PlaceKey: PlaceCoordKey(30.0, 120.0), }} d := DiffVisits(stored, sampleBackup()) if len(d.Added) != 2 { t.Fatalf("备份里两条都是新增,实际 %d", len(d.Added)) } if len(d.Removed) != 1 { t.Fatalf("站点补录那条应算「仅库里有」,实际 %d", len(d.Removed)) } if !d.Removed[0].UserAdded { t.Error("站点补录的标记要带上,页面上要区分开") } } // 真机偶尔有不带 UUID 的到访,只按 UUID 配对会让同一条既算新增又算仅站内有。 func TestDiffVisitsFallsBackWhenUUIDMissing(t *testing.T) { b := &Backup{ Visits: []Visit{ {SrcPK: 900, Arrival: tp("2026-09-01 10:00"), LocationSrcPK: ip(3)}, {SrcPK: 901, Arrival: tp("2026-09-02 10:00"), LocationSrcPK: ip(3)}, }, Locations: []Location{{SrcPK: 3, Name: "家", Lat: 24.1, Lon: 109.1}}, VisitUUID: map[int]string{}, // 一条都没有 UUID ActivityUUID: map[int]string{}, } stored := []StoredVisit{ {SrcPK: 1, Arrival: tp("2026-09-01 10:00"), PlaceKey: PlaceCoordKey(24.1, 109.1)}, {SrcPK: 2, Arrival: tp("2026-09-02 10:00"), PlaceKey: PlaceCoordKey(24.1, 109.1)}, } d := DiffVisits(stored, b) if len(d.Added) != 0 || len(d.Removed) != 0 { t.Fatalf("没有 UUID 时应按「到达时间 + 地点」兜底配对: +%d -%d", len(d.Added), len(d.Removed)) } if d.Same != 2 { t.Fatalf("两条都该配上,实际 %d", d.Same) } } // 兜底配对要认得出「同一条但时间被改过」——时间不同就配不上,此时按新增 + 仅站内有报出, // 总比静默当成同一条好。 func TestDiffVisitsFallbackNeedsSameTime(t *testing.T) { b := &Backup{ Visits: []Visit{{SrcPK: 900, Arrival: tp("2026-09-01 10:00"), LocationSrcPK: ip(3)}}, Locations: []Location{{SrcPK: 3, Name: "家", Lat: 24.1, Lon: 109.1}}, VisitUUID: map[int]string{}, } stored := []StoredVisit{{SrcPK: 1, Arrival: tp("2026-09-01 11:00"), PlaceKey: PlaceCoordKey(24.1, 109.1)}} d := DiffVisits(stored, b) if len(d.Added) != 1 || len(d.Removed) != 1 { t.Fatalf("时间对不上时该分别报出: +%d -%d", len(d.Added), len(d.Removed)) } } func TestDiffVisitsSkipsRowsWithoutArrival(t *testing.T) { b := sampleBackup() b.Visits = append(b.Visits, Visit{SrcPK: 557, LocationSrcPK: ip(3)}) d := DiffVisits(nil, b) if len(d.Added) != 2 { t.Fatalf("没有到达时间的行导入时就被跳过,不该算新增,实际 %d", len(d.Added)) } } func TestDiffPlacesByKey(t *testing.T) { b := sampleBackup() stored := []StoredPlace{ {SrcPK: 10, Key: PlaceCoordKey(24.1, 109.1), Name: "家", Lat: 24.1, Lon: 109.1, City: "柳州市"}, } d := DiffPlaces(stored, b) if len(d.Added) != 1 || d.Added[0].Name != "公司" { t.Fatalf("应只有「公司」是新增: %+v", d.Added) } if len(d.Removed) != 0 || len(d.Changed) != 0 || d.Same != 1 { t.Fatalf("「家」应配上且无差异: same=%d changed=%d removed=%d", d.Same, len(d.Changed), len(d.Removed)) } } func TestDiffPlacesReportsFieldChanges(t *testing.T) { b := sampleBackup() stored := []StoredPlace{ {SrcPK: 10, Key: PlaceCoordKey(24.1, 109.1), Name: "家", Lat: 24.1, Lon: 109.1, City: "桂林市"}, } d := DiffPlaces(stored, b) if len(d.Changed) != 1 { t.Fatalf("城市不同应算冲突,实际 %d", len(d.Changed)) } if f := d.Changed[0].Fields; len(f) != 1 || f[0].Label != "城市" || f[0].From != "桂林市" || f[0].To != "柳州市" { t.Fatalf("字段差异不对: %+v", f) } } func TestDiffActivities(t *testing.T) { b := sampleBackup() stored := []StoredActivity{ {SrcPK: 20, Name: "工作", UUID: uuidC}, {SrcPK: 21, Name: "站点自建"}, } d := DiffActivities(stored, b) if len(d.Added) != 1 || d.Added[0].Name != "散步" { t.Fatalf("「散步」应是新增: %+v", d.Added) } if len(d.Removed) != 1 || d.Removed[0].Name != "站点自建" { t.Fatalf("站点自建的活动应算仅库里有: %+v", d.Removed) } if d.Same != 1 { t.Fatalf("「工作」应配上,实际 same=%d", d.Same) } } // 活动改了名:UUID 还在,就该认出来是同一个,只报名称变化,而不是「删一个加一个」。 func TestDiffActivitiesRenameByUUID(t *testing.T) { b := sampleBackup() stored := []StoredActivity{{SrcPK: 20, Name: "上班", UUID: uuidC}} d := DiffActivities(stored, b) if len(d.Added) != 1 || len(d.Removed) != 0 { t.Fatalf("只该多出「散步」,「上班」应认出是同一个: +%d -%d", len(d.Added), len(d.Removed)) } if len(d.Changed) != 1 || d.Changed[0].Fields[0].From != "上班" || d.Changed[0].Fields[0].To != "工作" { t.Fatalf("应按 UUID 认出重命名: %+v", d.Changed) } } func TestPlaceCoordKeyStable(t *testing.T) { a := PlaceCoordKey(24.1, 109.1) if a != PlaceCoordKey(24.1, 109.1) { t.Fatal("同样的输入应得到同样的键") } if a == PlaceCoordKey(24.1, 109.2) { t.Fatal("坐标不同不该算同一个地点") } } // 站点把地点反查改名(坐标没动)时,到访层面不该报「换了地点」—— // 实测里这一条会让 17 条到访全变成假冲突。 func TestDiffVisitsIgnoresPlaceRename(t *testing.T) { b := &Backup{ Visits: []Visit{{SrcPK: 5, Arrival: tp("2026-09-01 10:00"), LocationSrcPK: ip(3)}}, Locations: []Location{{SrcPK: 3, Name: "河池市国民体质监测中心", Lat: 24.691827, Lon: 108.035268}}, VisitUUID: map[int]string{5: uuidA}, } stored := []StoredVisit{{ SrcPK: 1, UUID: uuidA, Arrival: tp("2026-09-01 10:00"), PlaceKey: PlaceCoordKey(24.691827, 108.035268), PlaceName: "体育馆美食街", }} d := DiffVisits(stored, b) if len(d.Changed) != 0 { t.Fatalf("只改了地点名不该算到访冲突: %+v", d.Changed[0].Fields) } if d.Same != 1 { t.Fatalf("应算作一致,实际 same=%d", d.Same) } } // 反之,地点层面要把改名报出来。 func TestDiffPlacesReportsRename(t *testing.T) { b := &Backup{Locations: []Location{ {SrcPK: 3, Name: "河池市国民体质监测中心", Lat: 24.691827, Lon: 108.035268, City: "河池市"}, }} stored := []StoredPlace{{ SrcPK: 10, Key: PlaceCoordKey(24.691827, 108.035268), Name: "体育馆美食街", Lat: 24.691827, Lon: 108.035268, City: "河池市", }} d := DiffPlaces(stored, b) if len(d.Added) != 0 || len(d.Removed) != 0 { t.Fatalf("坐标一样就是同一个地点: +%d -%d", len(d.Added), len(d.Removed)) } if len(d.Changed) != 1 { t.Fatalf("改名应报成一条冲突,实际 %d", len(d.Changed)) } f := d.Changed[0].Fields if len(f) != 1 || f[0].Label != "名称" || f[0].From != "体育馆美食街" || f[0].To != "河池市国民体质监测中心" { t.Fatalf("字段差异不对: %+v", f) } } // 没有坐标的地点不能都落到同一个键上,否则两个不同地点会互相认领。 func TestDiffPlacesWithoutCoordDoesNotCollide(t *testing.T) { b := &Backup{Locations: []Location{{SrcPK: 3, Name: "甲地"}}} stored := []StoredPlace{ {SrcPK: 10, Name: "甲地"}, {SrcPK: 11, Name: "乙地"}, } d := DiffPlaces(stored, b) if len(d.Added) != 0 || len(d.Removed) != 1 { t.Fatalf("应只配上同名的「甲地」: +%d -%d", len(d.Added), len(d.Removed)) } if d.Removed[0].Name != "乙地" { t.Fatalf("没配上的应是「乙地」,实际 %q", d.Removed[0].Name) } if d.Same != 1 { t.Fatalf("「甲地」应算一致,实际 %d", d.Same) } } // 站点把坐标纠偏过(名称没变)也要认得出是同一个地点。 func TestDiffPlacesMatchesByNameWhenCoordMoved(t *testing.T) { b := &Backup{Locations: []Location{{SrcPK: 3, Name: "家", Lat: 24.100001, Lon: 109.100001}}} stored := []StoredPlace{{ SrcPK: 10, Key: PlaceCoordKey(24.1, 109.1), Name: "家", Lat: 24.1, Lon: 109.1, }} d := DiffPlaces(stored, b) if len(d.Added) != 0 || len(d.Removed) != 0 { t.Fatalf("名称一样就该配上: +%d -%d", len(d.Added), len(d.Removed)) } if len(d.Changed) != 1 || d.Changed[0].Fields[0].Label != "纬度" { t.Fatalf("应报出坐标差异: %+v", d.Changed) } } // 真机一个坐标下会有好几条同名同坐标的地点(实测「柳州站」同一个坐标 3 条,备份里这类分组共 10 组)。 // 用单槽位 map 时,同一坐标最多只能配上一条,第 3 条永远判「备份新增」→ 每次合并都再插一条 // 重复地点、差异永远归不了零。 func TestDiffPlacesAlignsDuplicateNameCoordGroups(t *testing.T) { loc := func(pk int) Location { return Location{SrcPK: pk, Name: "柳州站", Lat: 24.307787, Lon: 109.388309} } sp := func(pk int) StoredPlace { return StoredPlace{SrcPK: pk, Key: PlaceCoordKey(24.307787, 109.388309), Name: "柳州站", Lat: 24.307787, Lon: 109.388309} } b := &Backup{Locations: []Location{loc(41), loc(42), loc(43)}} d := DiffPlaces([]StoredPlace{sp(11), sp(12), sp(13)}, b) if len(d.Added) != 0 || len(d.Removed) != 0 || len(d.Changed) != 0 { t.Fatalf("两边各 3 条同名同坐标应一一配上: +%d -%d ~%d", len(d.Added), len(d.Removed), len(d.Changed)) } if d.Same != 3 { t.Fatalf("应配上 3 条,实际 %d", d.Same) } // 反过来:站内真的少两条时,仍要老老实实报出「备份新增 2」—— // 多值配对不能变成「只要有同名同坐标就一律不算新增」。 d = DiffPlaces([]StoredPlace{sp(11)}, b) if len(d.Added) != 2 || len(d.Removed) != 0 || d.Same != 1 { t.Fatalf("站内只有 1 条时应报新增 2: +%d -%d same=%d", len(d.Added), len(d.Removed), d.Same) } } // 活动改了名(UUID 还在):那是活动层的差异,不该在挂着它的每条到访上重复报一遍。 // 实测真机 16 个活动改名会把 355 条到访全变成假冲突,而且逐条选「用实机」也改不掉活动名。 func TestDiffVisitsIgnoresActivityRename(t *testing.T) { b := &Backup{ Visits: []Visit{{SrcPK: 5, Arrival: tp("2026-09-01 10:00"), ActivitySrcPK: ip(7)}}, Activities: []Activity{{SrcPK: 7, Name: "吃饭"}}, VisitUUID: map[int]string{5: uuidA}, ActivityUUID: map[int]string{7: uuidC}, } stored := []StoredVisit{{ SrcPK: 1, UUID: uuidA, Arrival: tp("2026-09-01 10:00"), ActivityName: "餐厅", ActivityUUID: uuidC, }} d := DiffVisits(stored, b) if len(d.Changed) != 0 { t.Fatalf("同一个活动改名不该算到访冲突: %+v", d.Changed[0].Fields) } if d.Same != 1 { t.Fatalf("应算作一致,实际 same=%d", d.Same) } } // 换成了另一个活动才是真差异。 func TestDiffVisitsReportsActivityChange(t *testing.T) { b := &Backup{ Visits: []Visit{{SrcPK: 5, Arrival: tp("2026-09-01 10:00"), ActivitySrcPK: ip(7)}}, Activities: []Activity{{SrcPK: 7, Name: "吃饭"}, {SrcPK: 8, Name: "散步"}}, VisitUUID: map[int]string{5: uuidA}, ActivityUUID: map[int]string{7: uuidC, 8: uuidB}, } stored := []StoredVisit{{ SrcPK: 1, UUID: uuidA, Arrival: tp("2026-09-01 10:00"), ActivityName: "散步", ActivityUUID: uuidB, }} d := DiffVisits(stored, b) if len(d.Changed) != 1 { t.Fatalf("换了活动该报冲突,实际 %d 条", len(d.Changed)) } f := d.Changed[0].Fields if len(f) != 1 || f[0].Label != "活动" || f[0].From != "散步" || f[0].To != "吃饭" { t.Fatalf("活动差异不对: %+v", f) } } // 站点自建的活动没有 UUID,退不回身份就按名称比(老数据要保持原样)。 func TestDiffVisitsActivityWithoutUUIDFallsBackToName(t *testing.T) { b := &Backup{ Visits: []Visit{{SrcPK: 5, Arrival: tp("2026-09-01 10:00"), ActivitySrcPK: ip(7)}}, Activities: []Activity{{SrcPK: 7, Name: "吃饭"}}, VisitUUID: map[int]string{5: uuidA}, ActivityUUID: map[int]string{7: uuidC}, } // 名称一致 → 不报 same := []StoredVisit{{ SrcPK: 1, UUID: uuidA, Arrival: tp("2026-09-01 10:00"), ActivityName: "吃饭", }} if d := DiffVisits(same, b); len(d.Changed) != 0 { t.Fatalf("名称相同时不该报冲突: %+v", d.Changed[0].Fields) } // 名称不同 → 报 diff := []StoredVisit{{ SrcPK: 1, UUID: uuidA, Arrival: tp("2026-09-01 10:00"), ActivityName: "散步", }} d := DiffVisits(diff, b) if len(d.Changed) != 1 || d.Changed[0].Fields[0].Label != "活动" { t.Fatalf("没有 UUID 时应退回按名称比: %+v", d.Changed) } }