- 抽出 rond.MatchPlaces(stored, backup) PlaceMatch:坐标优先、退到「同名且相距 ≤1km」、 多值索引 + 认领即标记已用;DiffPlaces 与 web.mergePlaces 共用它。 - mergePlaces 原来是单槽位 byCoord/byName 自己再配一遍:同坐标/同名的多条备份地点全并到同一行 站内地点,到访被挂到错的分店(实测「茶颜悦色」两家分店相隔 8.3km,到访却指着同一条)。 现在直接用配对结果,再叠加 plan.AddPlaces 的新插入。 - loadStoredPlaces 与 mergePlaces 的地点查询都加 ORDER BY id:候选多于一个时靠顺序定谁配谁, 两侧必须一致,否则配对会飘。 - 删掉只写不读的 PlacePatch.CoordKey、ActivityPatch.Key(容易被误当成查找键)。 - 用例:TestMatchPlacesKeepsDuplicateNameGroupsDistinct(同一坐标/邻近同名都要一条配一条)、 TestMergePlacesMapsSameNameLocationsOneToOne(真库 + 真事务,两条同名备份地点必须映射到两条不同 地点);两处都做了变异验证(认领不标记已用 / 恢复单槽位映射 → 对应用例变红)。 - 探针库复测(从 dump 恢复、不动本地真库,49 组冲突全「用实机」):到访挂错地点(>1km)0/953 条、 地点被搬动 >1km 0 个;合并后再比对三类全 0(到访 0/47/0、地点 0/4/0、活动 0/0/0), 再点合并「按当前的选择没有要写入的改动」。全量验收 293 PASS / 0 FAIL / 0 SKIP。 已知取舍:勾「用实机」但没勾「并进来」时,若该到访在实机上的地点站内没有、且按 1km 规则也配不上, place_id 会被置空(旧行为是挂到一条远方的同名地点上——那正是这次要去掉的错配)。
421 lines
18 KiB
Go
421 lines
18 KiB
Go
package web
|
||
|
||
import (
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"strings"
|
||
"testing"
|
||
|
||
"rond-with-you/internal/rond"
|
||
)
|
||
|
||
// mergeBackup 走真实的合并处理函数;失败(重定向里带 err=)直接判错。
|
||
func mergeBackup(t *testing.T, f *movementFixture, token, body string) {
|
||
t.Helper()
|
||
if loc := mergeBackupAllowErr(t, f, token, body); strings.Contains(loc, "err=") {
|
||
t.Fatalf("合并失败: %s", loc)
|
||
}
|
||
}
|
||
|
||
// mergeBackupAllowErr 同上,但把落点交出来,供「预期会失败」的用例断言。
|
||
func mergeBackupAllowErr(t *testing.T, f *movementFixture, token, body string) string {
|
||
t.Helper()
|
||
rec := httptest.NewRecorder()
|
||
req := httptest.NewRequest("POST", "/admin/preview/"+token+"/merge", strings.NewReader(body))
|
||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||
req.SetPathValue("token", token)
|
||
f.srv.adminBackupMerge(rec, req.WithContext(f.ctx))
|
||
if rec.Code != http.StatusSeeOther {
|
||
t.Fatalf("合并应跳回后台: code=%d body=%s", rec.Code, rec.Body.String())
|
||
}
|
||
return rec.Header().Get("Location")
|
||
}
|
||
|
||
// addExtraVisit 造一条「仅站内有」的到访:没有留档,于是没有 UUID,比对时必然落在仅站内有里。
|
||
func (f *movementFixture) addExtraVisit(t *testing.T, pk int, remark string) {
|
||
t.Helper()
|
||
if _, err := f.pg.ExecContext(f.ctx, `INSERT INTO visits
|
||
(dataset_id, src_pk, arrival, departure, is_user_added, remark)
|
||
VALUES ($1,$2,now(),now(),true,$3)`, f.dsID, pk, remark); err != nil {
|
||
t.Fatalf("插入补录到访失败: %v", err)
|
||
}
|
||
}
|
||
|
||
func (f *movementFixture) scalar(t *testing.T, q string, args ...any) int {
|
||
t.Helper()
|
||
var n int
|
||
if err := f.pg.QueryRowContext(f.ctx, q, args...).Scan(&n); err != nil {
|
||
t.Fatalf("查询失败: %v", err)
|
||
}
|
||
return n
|
||
}
|
||
|
||
// 合并把备份里多出来的记录写进去,而且写完要「复原」——同一份备份再比一次必须一条不差。
|
||
//
|
||
// 这条测试同时钉住三件事:
|
||
// - 新增行用 nextSrcPK 发新号,且**写进 entity_raw**(否则下次比对认不出 UUID);
|
||
// - 位移/天气的外键指向库里的新编号(写错就成孤儿);
|
||
// - 合并是幂等的:再传同一份备份,差异归零。
|
||
//
|
||
// 注意:这里库内编号与备份逐条相同(夹具导入的就是 f.src 本身,实测 823/823),
|
||
// 所以走的是「编号直接命中」的快路径。编号重排过的真实场景见
|
||
// TestBackupMergeRenumberedBackupRestoresOnlyMissing。
|
||
func TestBackupMergeRestoresMissingVisits(t *testing.T) {
|
||
f := newMovementFixture(t)
|
||
|
||
visitCount := func() int {
|
||
return f.scalar(t, `SELECT count(*) FROM visits WHERE dataset_id=$1`, f.dsID)
|
||
}
|
||
all := visitCount()
|
||
movCount := f.scalar(t, `SELECT count(*) FROM movements WHERE dataset_id=$1`, f.dsID)
|
||
weatherCount := f.scalar(t, `SELECT count(*) FROM weather WHERE dataset_id=$1`, f.dsID)
|
||
// 原始数据里就有指向「因缺到达时间而没入库」的到访的位移,先记下基线再比
|
||
orphanQ := `SELECT count(*) FROM movements m WHERE m.dataset_id=$1
|
||
AND (m.from_visit_src IS NULL OR NOT EXISTS (
|
||
SELECT 1 FROM visits v WHERE v.dataset_id=$1 AND v.src_pk=m.from_visit_src))`
|
||
orphans0 := f.scalar(t, orphanQ, f.dsID)
|
||
|
||
// 制造差异:按**站点删到访的方式**删掉最早的两条(见 editVisitDelete)——
|
||
// 位移删掉、天气的归属置空、到访删掉。直接 DELETE visits 会留下孤儿天气,
|
||
// 那是测试手法造成的,不是产品行为。
|
||
var pk1, pk2 int
|
||
if err := f.pg.QueryRowContext(f.ctx, `SELECT
|
||
(SELECT src_pk FROM visits WHERE dataset_id=$1 ORDER BY arrival LIMIT 1),
|
||
(SELECT src_pk FROM visits WHERE dataset_id=$1 ORDER BY arrival OFFSET 1 LIMIT 1)`,
|
||
f.dsID).Scan(&pk1, &pk2); err != nil {
|
||
t.Fatalf("取待删到访失败: %v", err)
|
||
}
|
||
for _, q := range []string{
|
||
`DELETE FROM movements WHERE dataset_id=$1 AND (from_visit_src IN ($2,$3) OR to_visit_src IN ($2,$3))`,
|
||
`UPDATE weather SET visit_src=NULL WHERE dataset_id=$1 AND visit_src IN ($2,$3)`,
|
||
`DELETE FROM visits WHERE dataset_id=$1 AND src_pk IN ($2,$3)`,
|
||
} {
|
||
if _, err := f.pg.ExecContext(f.ctx, q, f.dsID, pk1, pk2); err != nil {
|
||
t.Fatalf("预置删除失败: %v", err)
|
||
}
|
||
}
|
||
if got := visitCount(); got != all-2 {
|
||
t.Fatalf("预置失败:应有 %d 条,实际 %d", all-2, got)
|
||
}
|
||
|
||
token, page := previewBackupToken(t, f, f.src)
|
||
if a, _, _ := visitCounts(t, page); a != 2 {
|
||
t.Fatalf("差异页应报 2 条新增,实际 %d", a)
|
||
}
|
||
|
||
mergeBackup(t, f, token, "add_new=1")
|
||
|
||
if got := visitCount(); got != all {
|
||
t.Fatalf("合并后到访数应回到 %d,实际 %d", all, got)
|
||
}
|
||
// 新补进来的那两条必须各有一份留档(下次比对就靠它认 UUID)。
|
||
// 被删掉的行会留下孤儿留档——那是老数据,不影响,所以只查新编号。
|
||
if got := f.scalar(t, `SELECT count(*) FROM entity_raw er
|
||
WHERE er.dataset_id=$1 AND er.entity='ZVISIT' AND EXISTS (
|
||
SELECT 1 FROM visits v WHERE v.dataset_id=$1 AND v.src_pk=er.src_pk)`, f.dsID); got != all {
|
||
t.Errorf("每条在库的到访都该有原始行留档,实际 %d / %d", got, all)
|
||
}
|
||
if got := f.scalar(t, `SELECT count(*) FROM movements WHERE dataset_id=$1`, f.dsID); got != movCount {
|
||
t.Errorf("位移应恢复到 %d,实际 %d", movCount, got)
|
||
}
|
||
if got := f.scalar(t, `SELECT count(*) FROM weather WHERE dataset_id=$1`, f.dsID); got != weatherCount {
|
||
t.Errorf("天气应恢复到 %d,实际 %d", weatherCount, got)
|
||
}
|
||
// 合并不该新增孤儿位移(原始数据里本来就有一些,见 orphanQ 的注释)
|
||
if orphans := f.scalar(t, orphanQ, f.dsID); orphans > orphans0 {
|
||
t.Errorf("孤儿位移变多了:%d → %d", orphans0, orphans)
|
||
}
|
||
|
||
// 幂等:再比一次应当完全一致(地点也一样——同名同坐标的重复地点必须一一配上,
|
||
// 否则每次合并都会再插一条重复地点)
|
||
page = previewBackup(t, f, f.src)
|
||
if a, g, c := visitCounts(t, page); a != 0 || g != 0 || c != 0 {
|
||
t.Fatalf("合并后再比对应当没有差异,实际 新增 %d / 仅站内有 %d / 冲突 %d", a, g, c)
|
||
}
|
||
if a, g, _ := overviewCounts(t, page, "地点"); a != 0 || g != 0 {
|
||
t.Fatalf("合并后再比对,地点应当归零,实际 新增 %d / 仅站内有 %d"+
|
||
"(重复地点会被反复当成「备份新增」→ 每合并一次多一条)", a, g)
|
||
}
|
||
}
|
||
|
||
// 「本地 + **后来新增的备份**」合并的真实形态:备份里的编号被 rond 全部重排过,
|
||
// 与库里的编号对不上,而备份里还多了几条新记录。
|
||
//
|
||
// 这条要钉住的是最贵的一种错:如果比对退回按编号对齐,那 800 多条老记录会被判成
|
||
// 「备份新增 + 仅站内有」,用户勾一下「并进来」就把整份足迹**复制一遍**(数量翻倍、
|
||
// 地图上每个点都重影)。必须在平移编号的前提下,差异仍然只有真正缺的那 2 条。
|
||
func TestBackupMergeRenumberedBackupRestoresOnlyMissing(t *testing.T) {
|
||
f := newMovementFixture(t)
|
||
|
||
visitCount := func() int {
|
||
return f.scalar(t, `SELECT count(*) FROM visits WHERE dataset_id=$1`, f.dsID)
|
||
}
|
||
all := visitCount()
|
||
movCount := f.scalar(t, `SELECT count(*) FROM movements WHERE dataset_id=$1`, f.dsID)
|
||
weatherCount := f.scalar(t, `SELECT count(*) FROM weather WHERE dataset_id=$1`, f.dsID)
|
||
if all == 0 || movCount == 0 {
|
||
t.Skip("这份备份没有足够的到访/位移可测")
|
||
}
|
||
|
||
// ① 库里的编号整体平移:等价于「这批数据其实来自另一份编号完全不同的备份」
|
||
f.shiftSrcPKs(t, 500000)
|
||
|
||
// ② 再按**站点删到访的方式**删掉最早两条:备份里还有它们,所以差异应当正好是 2 条。
|
||
// 和平移前的用例一样,必须连带清位移、清天气归属,否则留下的是测试手法造成的孤儿。
|
||
var pk1, pk2 int
|
||
if err := f.pg.QueryRowContext(f.ctx, `SELECT
|
||
(SELECT src_pk FROM visits WHERE dataset_id=$1 ORDER BY arrival LIMIT 1),
|
||
(SELECT src_pk FROM visits WHERE dataset_id=$1 ORDER BY arrival OFFSET 1 LIMIT 1)`,
|
||
f.dsID).Scan(&pk1, &pk2); err != nil {
|
||
t.Fatalf("取待删到访失败: %v", err)
|
||
}
|
||
for _, q := range []string{
|
||
`DELETE FROM movements WHERE dataset_id=$1 AND (from_visit_src IN ($2,$3) OR to_visit_src IN ($2,$3))`,
|
||
`UPDATE weather SET visit_src=NULL WHERE dataset_id=$1 AND visit_src IN ($2,$3)`,
|
||
`DELETE FROM visits WHERE dataset_id=$1 AND src_pk IN ($2,$3)`,
|
||
} {
|
||
if _, err := f.pg.ExecContext(f.ctx, q, f.dsID, pk1, pk2); err != nil {
|
||
t.Fatalf("预置删除失败: %v", err)
|
||
}
|
||
}
|
||
if got := visitCount(); got != all-2 {
|
||
t.Fatalf("预置失败:应有 %d 条,实际 %d", all-2, got)
|
||
}
|
||
|
||
token, page := previewBackupToken(t, f, f.src)
|
||
if a, g, c := visitCounts(t, page); a != 2 || g != 0 || c != 0 {
|
||
t.Fatalf("编号重排过、只缺 2 条 ⇒ 差异应当正好 2 条新增;实际 新增 %d 仅站内有 %d 冲突 %d"+
|
||
"(判成 800+ 就是把整份足迹当新记录,合并会复制一遍)", a, g, c)
|
||
}
|
||
|
||
mergeBackup(t, f, token, "add_new=1")
|
||
|
||
if got := visitCount(); got != all {
|
||
t.Fatalf("合并后到访数应回到 %d 且不重不漏,实际 %d", all, got)
|
||
}
|
||
if got := f.scalar(t, `SELECT count(*) FROM movements WHERE dataset_id=$1`, f.dsID); got != movCount {
|
||
t.Errorf("位移应恢复到 %d,实际 %d(恢复到新编号时外键映射错了)", movCount, got)
|
||
}
|
||
if got := f.scalar(t, `SELECT count(*) FROM weather WHERE dataset_id=$1`, f.dsID); got != weatherCount {
|
||
t.Errorf("天气应恢复到 %d,实际 %d", weatherCount, got)
|
||
}
|
||
// 补进来的那两条必须真的挂回了某个经历,不能因为编号变了就成孤儿
|
||
if got := f.scalar(t, `SELECT count(*) FROM movements m WHERE m.dataset_id=$1
|
||
AND m.from_visit_src IS NOT NULL AND NOT EXISTS (
|
||
SELECT 1 FROM visits v WHERE v.dataset_id=$1 AND v.src_pk=m.from_visit_src)`, f.dsID); got != 0 {
|
||
t.Errorf("恢复后出现了 %d 条起点悬空的位移", got)
|
||
}
|
||
// 地点是按**坐标**对齐的(不是按编号),所以编号平移不该让补回来的到访挂空 ——
|
||
// place_id 可空,映射错了不会报错,只会变成地图上没地点的孤点到访。
|
||
if got := f.scalar(t, `SELECT count(*) FROM visits WHERE dataset_id=$1 AND place_id IS NULL`, f.dsID); got != 0 {
|
||
t.Errorf("恢复后有 %d 条到访没挂上地点(按编号映射就会这样)", got)
|
||
}
|
||
|
||
// 幂等:再比一次必须归零(否则每合并一次就多一批)
|
||
page = previewBackup(t, f, f.src)
|
||
if a, g, c := visitCounts(t, page); a != 0 || g != 0 || c != 0 {
|
||
t.Fatalf("合并后再比对应当没有差异,实际 新增 %d 仅站内有 %d 冲突 %d", a, g, c)
|
||
}
|
||
if a, g, _ := overviewCounts(t, page, "地点"); a != 0 || g != 0 {
|
||
t.Fatalf("合并后地点差异也该归零,实际 新增 %d 仅站内有 %d", a, g)
|
||
}
|
||
}
|
||
|
||
// 冲突逐条选「用实机」就把备份的值写进去,选「用站内」就原样不动。
|
||
func TestBackupMergeConflictChoice(t *testing.T) {
|
||
f := newMovementFixture(t)
|
||
|
||
var pk int
|
||
if err := f.pg.QueryRowContext(f.ctx, `UPDATE visits SET remark='站内改过的备注'
|
||
WHERE id = (SELECT id FROM visits WHERE dataset_id=$1 ORDER BY src_pk LIMIT 1)
|
||
RETURNING src_pk`, f.dsID).Scan(&pk); err != nil {
|
||
t.Fatalf("改备注失败: %v", err)
|
||
}
|
||
// 这条的 UUID 就是页面上的冲突键
|
||
var b64 string
|
||
if err := f.pg.QueryRowContext(f.ctx, `SELECT COALESCE(raw #>> '{ZIDENTIFIER_,$blob}','')
|
||
FROM entity_raw WHERE dataset_id=$1 AND entity='ZVISIT' AND src_pk=$2`, f.dsID, pk).Scan(&b64); err != nil {
|
||
t.Fatalf("取 UUID 失败: %v", err)
|
||
}
|
||
uuid := uuidFromBase64(b64)
|
||
if uuid == "" {
|
||
t.Fatal("这条到访应当有 UUID")
|
||
}
|
||
|
||
remark := func() string {
|
||
var s string
|
||
if err := f.pg.QueryRowContext(f.ctx, `SELECT COALESCE(remark,'') FROM visits
|
||
WHERE dataset_id=$1 AND src_pk=$2`, f.dsID, pk).Scan(&s); err != nil {
|
||
t.Fatalf("读备注失败: %v", err)
|
||
}
|
||
return s
|
||
}
|
||
|
||
// 用站内:备注保持站里那份
|
||
token, _ := previewBackupToken(t, f, f.src)
|
||
mergeBackup(t, f, token, "vc_"+uuid+"=db")
|
||
if got := remark(); got != "站内改过的备注" {
|
||
t.Fatalf("选「用站内」不该改动,实际 %q", got)
|
||
}
|
||
|
||
// 用实机:备注被备份里的值(空)覆盖回去
|
||
token, _ = previewBackupToken(t, f, f.src)
|
||
mergeBackup(t, f, token, "vc_"+uuid+"=backup")
|
||
if got := remark(); got != "" {
|
||
t.Fatalf("选「用实机」应覆盖成备份里的值,实际 %q", got)
|
||
}
|
||
}
|
||
|
||
// 什么都不勾、冲突全跳过时,合并应当什么都不写。
|
||
func TestBackupMergeNoopKeepsData(t *testing.T) {
|
||
f := newMovementFixture(t)
|
||
before := f.scalar(t, `SELECT count(*) FROM visits WHERE dataset_id=$1`, f.dsID)
|
||
|
||
token, _ := previewBackupToken(t, f, f.src)
|
||
mergeBackup(t, f, token, "add_new=0")
|
||
|
||
if got := f.scalar(t, `SELECT count(*) FROM visits WHERE dataset_id=$1`, f.dsID); got != before {
|
||
t.Fatalf("没勾任何改动却动了数据: %d → %d", before, got)
|
||
}
|
||
}
|
||
|
||
// 删除必须「勾了才删」:没勾的一个都不动,勾了的那条(连带它的位移)才消失。
|
||
func TestBackupMergeDeletesOnlyChecked(t *testing.T) {
|
||
f := newMovementFixture(t)
|
||
visitCount := func() int {
|
||
return f.scalar(t, `SELECT count(*) FROM visits WHERE dataset_id=$1`, f.dsID)
|
||
}
|
||
before := visitCount()
|
||
|
||
f.addExtraVisit(t, 900001, "稍后删掉")
|
||
f.addExtraVisit(t, 900002, "留着不动")
|
||
if got := visitCount(); got != before+2 {
|
||
t.Fatalf("预置失败: %d", got)
|
||
}
|
||
|
||
// 只勾一条
|
||
token, page := previewBackupToken(t, f, f.src)
|
||
if _, g, _ := visitCounts(t, page); g != 2 {
|
||
t.Fatalf("差异页应报 2 条仅站内有,实际 %d", g)
|
||
}
|
||
mergeBackup(t, f, token, "dv_900001=1")
|
||
|
||
if got := visitCount(); got != before+1 {
|
||
t.Fatalf("应只删掉勾选的那条,实际剩 %d(原 %d)", got, before)
|
||
}
|
||
if n := f.scalar(t, `SELECT count(*) FROM visits WHERE dataset_id=$1 AND src_pk=900002`, f.dsID); n != 1 {
|
||
t.Error("没勾的那条不该被动")
|
||
}
|
||
// 删掉的到访不该留下留档
|
||
if n := f.scalar(t, `SELECT count(*) FROM entity_raw WHERE dataset_id=$1
|
||
AND entity='ZVISIT' AND src_pk=900001`, f.dsID); n != 0 {
|
||
t.Error("删除到访时要连它的原始行留档一起清掉")
|
||
}
|
||
|
||
// 再合并一次、一条都不勾:剩下那条必须还在
|
||
token, _ = previewBackupToken(t, f, f.src)
|
||
mergeBackup(t, f, token, "add_new=0")
|
||
if got := visitCount(); got != before+1 {
|
||
t.Fatalf("不勾就不该删,实际 %d", got)
|
||
}
|
||
}
|
||
|
||
// 名下有到访的地点必须拒绝删除——那个外键是 ON DELETE CASCADE,
|
||
// 真删下去会把到访一起带走。拒绝后还要整体回滚,别留下半截。
|
||
func TestBackupMergeRefusesDeletingPlaceInUse(t *testing.T) {
|
||
f := newMovementFixture(t)
|
||
visitCount := func() int {
|
||
return f.scalar(t, `SELECT count(*) FROM visits WHERE dataset_id=$1`, f.dsID)
|
||
}
|
||
beforeVisits := visitCount()
|
||
|
||
// 一个没有到访的空地点:允许删
|
||
if _, err := f.pg.ExecContext(f.ctx, `INSERT INTO places (dataset_id, src_pk, name, lat, lon)
|
||
VALUES ($1, 910001, '临时空地点', 30.0, 120.0)`, f.dsID); err != nil {
|
||
t.Fatalf("插入地点失败: %v", err)
|
||
}
|
||
// 一个有到访的地点:必须拒绝
|
||
if _, err := f.pg.ExecContext(f.ctx, `INSERT INTO places (dataset_id, src_pk, name, lat, lon)
|
||
VALUES ($1, 910002, '有到访的地点', 31.0, 121.0)`, f.dsID); err != nil {
|
||
t.Fatalf("插入地点失败: %v", err)
|
||
}
|
||
if _, err := f.pg.ExecContext(f.ctx, `INSERT INTO visits (dataset_id, src_pk, place_id, arrival, departure)
|
||
VALUES ($1, 910003, (SELECT id FROM places WHERE dataset_id=$1 AND src_pk=910002), now(), now())`,
|
||
f.dsID); err != nil {
|
||
t.Fatalf("插入到访失败: %v", err)
|
||
}
|
||
|
||
// 先删空地点:应当成功
|
||
token, _ := previewBackupToken(t, f, f.src)
|
||
mergeBackup(t, f, token, "dp_910001=1")
|
||
if n := f.scalar(t, `SELECT count(*) FROM places WHERE dataset_id=$1 AND src_pk=910001`, f.dsID); n != 0 {
|
||
t.Fatal("空地点应当被删掉")
|
||
}
|
||
|
||
// 再删有到访的地点:应当被拒绝
|
||
token, _ = previewBackupToken(t, f, f.src)
|
||
loc := mergeBackupAllowErr(t, f, token, "dp_910002=1")
|
||
if !strings.Contains(loc, "err=") {
|
||
t.Fatal("名下有到访的地点应当拒绝删除")
|
||
}
|
||
if n := f.scalar(t, `SELECT count(*) FROM places WHERE dataset_id=$1 AND src_pk=910002`, f.dsID); n != 1 {
|
||
t.Error("被拒绝后地点不该消失")
|
||
}
|
||
if got := visitCount(); got != beforeVisits+1 {
|
||
t.Errorf("被拒绝后到访数不该变,实际 %d(期望 %d)", got, beforeVisits+1)
|
||
}
|
||
}
|
||
|
||
// 两条同名备份地点在合并时必须各配一条站内地点。以前这里用**单槽位** map 自己再配一遍,
|
||
// 两条同名地点会映射到同一条站内地点上,到访于是被挂到错的分店——实测「茶颜悦色」两家
|
||
// 分店相隔 8.3km,到访却都指向同一条。现在配对直接沿用比对阶段那一份(rond.MatchPlaces)。
|
||
func TestMergePlacesMapsSameNameLocationsOneToOne(t *testing.T) {
|
||
f := newMovementFixture(t)
|
||
|
||
// 两条同名的站内地点,放在不太可能与真实数据撞车的坐标上
|
||
if _, err := f.pg.ExecContext(f.ctx, `INSERT INTO places (dataset_id, src_pk, name, lat, lon)
|
||
VALUES ($1, 920001, '同名测试店', 1.000000, 1.000000),
|
||
($1, 920002, '同名测试店', 1.100000, 1.100000)`, f.dsID); err != nil {
|
||
t.Fatalf("插入站内地点失败: %v", err)
|
||
}
|
||
t.Cleanup(func() {
|
||
f.pg.ExecContext(f.ctx, `DELETE FROM places WHERE dataset_id=$1 AND src_pk IN (920001,920002)`, f.dsID)
|
||
})
|
||
|
||
// 备份各一条同名地点,分别距上面两条约 700m(属于坐标纠偏,该配上);
|
||
// 与另一条相距 15km,远超 1km 上限,不该被配走
|
||
b := &rond.Backup{Locations: []rond.Location{
|
||
{SrcPK: 41, Name: "同名测试店", Lat: 1.004500, Lon: 1.004500},
|
||
{SrcPK: 42, Name: "同名测试店", Lat: 1.095500, Lon: 1.095500},
|
||
}}
|
||
|
||
var placeID map[int]int64
|
||
if err := f.srv.withTx(f.ctx, func(ex execer) error {
|
||
var err error
|
||
placeID, err = f.srv.mergePlaces(f.ctx, ex, f.dsID, b, rond.MergePlan{}, &mergeStats{})
|
||
return err
|
||
}); err != nil {
|
||
t.Fatalf("mergePlaces 失败: %v", err)
|
||
}
|
||
if len(placeID) != 2 {
|
||
t.Fatalf("两条都该配上,实际 %d 条", len(placeID))
|
||
}
|
||
if placeID[41] == placeID[42] {
|
||
t.Fatalf("两条同名备份地点被映射到同一条站内地点上: 41→%d 42→%d", placeID[41], placeID[42])
|
||
}
|
||
srcOf := func(id int64) int {
|
||
t.Helper()
|
||
var pk int
|
||
if err := f.pg.QueryRowContext(f.ctx, `SELECT src_pk FROM places WHERE id=$1`, id).Scan(&pk); err != nil {
|
||
t.Fatalf("反查 id=%d 失败: %v", id, err)
|
||
}
|
||
return pk
|
||
}
|
||
if got := srcOf(placeID[41]); got != 920001 {
|
||
t.Errorf("备份 #41 应配到最近的 920001,实际 %d", got)
|
||
}
|
||
if got := srcOf(placeID[42]); got != 920002 {
|
||
t.Errorf("备份 #42 应配到最近的 920002,实际 %d", got)
|
||
}
|
||
}
|