继上一轮修「活动颜色为 NULL」之后,生产重新导出 `data/20260929.rondbackup` 依旧导不回 rond。 这次拿**最后一次被真机接受的包**(20260920-fixed-A-数据修复)当参照逐项比对:SQLite 头、31 表 / 47 索引 / 建表语句 / 列定义、`Z_METADATA.Z_PLIST` 与 `Z_MODELCACHE`(逐字节)、值类型分布、文本合法 字节、BLOB 长度、UUID 唯一性、关系完整性、`Z_OPT`、行级时间不变量——全部一致;唯一差异是 **`ZTRANSPORT` 里出现了两条同名「公交」(Z_PK 2 与 4),而真机与可导入的 A 包都只有一条**。 根因:`mergeVisits` 把实机的 `ZTRANSPORT` 编号原样写进 `movements.transport_src`。站点没有交通方式表, 方式只活在位移的 transport_* 四列里,两套编号空间同一个数字含义不同(实测 src=4:站点=电🐔、实机=公交, src 3/5/6 同样撞车)。于是同一个 src 带上两种含义:导出按 src 聚合只能取一个名字 → 另一种含义的位移挂错 方式;更糟的是吐出两条同名方式,真机那边是模型唯一约束级别的违规 → 整包拒收。 修复两处: 1. 合并侧 `mergeVisits`:按名称把实机方式归一到站点编号(新增 `(*Server).transportsByName`:同名沿用 站点已有的编号,没有才 `nextSrcPK(transportSeq)` 新发一个)。 2. 导出侧 `queryTransports`:改为按**名称**归组(名称就是身份),用量最多的名称优先认领编号,其余编号的 位移改挂到它上面;并清掉「基线里同名但编号已被归并掉」的旧行——`keepMissing` 会把它原样带回,于是又是 两条同名。这段清理必须排在所有托管表写完之后,否则兜底判据看到的还是基线里未重写的 ZMOVEMENT。 真实数据效果(本地库,与生产同一形状):方式恢复成站点原本那套 1火车/2公交/3高铁/4电🐔/5地铁/6步行、 无重名;位移各挂回自己的方式(电🐔 336、地铁 25、高铁 23、公交 19、火车 9、步行 3——修复前只有 6 条能对上)。 用例:`TestExportMergesDuplicateTransportNames`(同名归并 + 位移挂回自己那条 + 无悬空引用)、 `TestMergeKeepsTransportNumberingConsistent`(合并后同一 transport_src 不许有两种名称,附「位移确实被补 回来了」的哨兵断言避免写成空用例)。三处都做了变异验证:去掉改挂 / 去掉旧行清理 / 合并照抄实机编号 → 对应用例分别变红。 全量验收 295 PASS / 0 FAIL / 0 SKIP。
472 lines
21 KiB
Go
472 lines
21 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)
|
||
}
|
||
}
|
||
|
||
// 交通方式的编号是**两套空间**:站点用自己的 transport_src 编号,而备份里的 m.TransportSrc 是
|
||
// 实机的编号。合并时照抄实机编号,会让同一个 src 带上两种含义(实测 src=4 既是「电🐔」又是
|
||
// 「公交」)——导出时按名称归并前会吐出两条同名方式,真机整包拒收。合并必须按名称把实机的方式
|
||
// 归一到站点的编号上。
|
||
func TestMergeKeepsTransportNumberingConsistent(t *testing.T) {
|
||
f := newMovementFixture(t)
|
||
ctx, pg, dsID := f.ctx, f.pg, f.dsID
|
||
movCount := func() int {
|
||
return f.scalar(t, `SELECT count(*) FROM movements WHERE dataset_id=$1`, dsID)
|
||
}
|
||
before := movCount()
|
||
|
||
// 把站点所有方式改名(编辑页允许改方式名,这就是真实操作):于是站点的「1 号」与实机的
|
||
// 「1 号」含义不同——两套编号空间撞车的形状
|
||
if _, err := pg.ExecContext(ctx, `UPDATE movements SET transport_name = transport_name || '(站内)'
|
||
WHERE dataset_id=$1 AND transport_src IS NOT NULL`, dsID); err != nil {
|
||
t.Fatalf("预置改名失败: %v", err)
|
||
}
|
||
|
||
// 删掉一条**带交通方式**的位移的端点到访,好让合并把它连同位移一起补回来
|
||
// (没有新到访就不会插入任何位移,用例会变成空的)
|
||
var pk int
|
||
if err := pg.QueryRowContext(ctx, `SELECT from_visit_src FROM movements
|
||
WHERE dataset_id=$1 AND transport_src IS NOT NULL AND from_visit_src IS NOT NULL
|
||
ORDER BY id LIMIT 1`, dsID).Scan(&pk); err != nil {
|
||
t.Fatalf("挑端点到访失败: %v", err)
|
||
}
|
||
for _, q := range []string{
|
||
`DELETE FROM movements WHERE dataset_id=$1 AND (from_visit_src=$2 OR to_visit_src=$2)`,
|
||
`UPDATE weather SET visit_src=NULL WHERE dataset_id=$1 AND visit_src=$2`,
|
||
`DELETE FROM visits WHERE dataset_id=$1 AND src_pk=$2`,
|
||
} {
|
||
if _, err := pg.ExecContext(ctx, q, dsID, pk); err != nil {
|
||
t.Fatalf("预置删除失败: %v", err)
|
||
}
|
||
}
|
||
|
||
token, _ := previewBackupToken(t, f, f.src)
|
||
mergeBackup(t, f, token, "add_new=1")
|
||
|
||
if got := movCount(); got < before {
|
||
t.Fatalf("合并没有把删掉的位移补回来(%d → %d),这条用例会变成空的", before, got)
|
||
}
|
||
n := f.scalar(t, `SELECT count(*) FROM (SELECT transport_src FROM movements
|
||
WHERE dataset_id=$1 AND transport_src IS NOT NULL
|
||
GROUP BY transport_src HAVING count(DISTINCT transport_name) > 1) x`, dsID)
|
||
if n != 0 {
|
||
t.Fatalf("%d 个交通方式编号带上了两种名称(实机编号被照抄进站点的编号空间了)", n)
|
||
}
|
||
}
|