Files
tamakyi 10d23e03cf 合并:重复地点不再反复判「备份新增」,到访的活动改按身份比
- DiffPlaces 的 byCoord/byName 改为多值索引 + take() 逐条认领未用项:真机一个坐标下会有
  多条同名同坐标的地点(实测「柳州站」3 条),单槽位时同一坐标最多只配上 2 条,第 3 条
  永远判「备份新增」⇒ 每次合并都再插一条重复地点、差异永远归不了零(实测 368→438→439)。
  修完:地点新增 70→67、合并后再比对归零、再点合并直接「没有要写入的改动」。
- 到访的「活动」由按名称比改为按身份(ZUID_)比,两边都缺 UUID 才退回名称:16 个活动改名
  曾把 355 条到访全变成同因冲突,且逐条选「用实机」只写回 activity_id(映射回同一个活动)
  改不掉名字。loadStoredVisits 为此多 JOIN 一次 entity_raw 取 ZACTIVITY.ZUID_。
- 回归用例 4 条(重复地点一一配上、活动改名不算冲突、换活动仍要报、缺 UUID 退回名称)+
  「合并后地点差异归零」断言 3 处;两处改动都做过变异验证。
- 实测(09-28 备份并进 09-14 数据集):到访冲突 253→6,合并两次地点数 438→439 变为 435→435。
2026-09-28 19:31:21 +08:00

222 lines
8.9 KiB
Go

package web
import (
"bytes"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"testing"
)
// previewBackup 走真实的上传处理函数:POST → 303 → GET 差异页,返回渲染结果。
func previewBackup(t *testing.T, f *movementFixture, path string) string {
t.Helper()
_, page := previewBackupToken(t, f, path)
return page
}
// previewBackupToken 同上,但把令牌也交出来,供后续的合并步骤使用。
func previewBackupToken(t *testing.T, f *movementFixture, path string) (string, string) {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("读取 %s 失败: %v", path, err)
}
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
fw, err := mw.CreateFormFile("backup", filepath.Base(path))
if err != nil {
t.Fatalf("构造上传表单失败: %v", err)
}
if _, err := fw.Write(data); err != nil {
t.Fatalf("写入上传内容失败: %v", err)
}
mw.Close()
rec := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/admin/preview", &buf)
req.Header.Set("Content-Type", mw.FormDataContentType())
f.srv.adminBackupPreview(rec, req.WithContext(f.ctx))
if rec.Code != http.StatusSeeOther {
t.Fatalf("上传应跳转到差异页: code=%d body=%s", rec.Code, rec.Body.String())
}
loc := rec.Header().Get("Location")
token := strings.TrimPrefix(loc, "/admin/preview/")
if token == loc || len(token) != stashTokenLen {
t.Fatalf("差异页地址不对: %q", loc)
}
rec = httptest.NewRecorder()
req = httptest.NewRequest("GET", loc, nil).WithContext(f.ctx)
req.SetPathValue("token", token)
f.srv.adminBackupPreviewPage(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("差异页渲染失败: code=%d body=%s", rec.Code, rec.Body.String())
}
return token, rec.Body.String()
}
// visitCounts 从概览表里取出「到访」那一行的 (备份新增, 仅站内有, 冲突)。
func visitCounts(t *testing.T, page string) (added, gone, clash int) {
t.Helper()
return overviewCounts(t, page, "到访")
}
// overviewCounts 取概览表里任意一行(到访 / 地点 / 活动类型)的三个计数。
// 不能全页 Contains 数字——三行有同样的 data-label,会张冠李戴。
func overviewCounts(t *testing.T, page, label string) (added, gone, clash int) {
t.Helper()
row := regexp.MustCompile(`(?s)<td>` + label + `</td>(.*?)</tr>`).FindStringSubmatch(page)
if row == nil {
t.Fatalf("概览表里找不到「%s」行", label)
}
nums := regexp.MustCompile(`data-label="(备份新增|仅站内有|冲突)">(\d+)<`).FindAllStringSubmatch(row[1], -1)
if len(nums) != 3 {
t.Fatalf("「%s」行应有三个计数,实际 %d", label, len(nums))
}
for _, n := range nums {
v, _ := strconv.Atoi(n[2])
switch n[1] {
case "备份新增":
added = v
case "仅站内有":
gone = v
case "冲突":
clash = v
}
}
return
}
// shiftSrcPKs 把库里的编号整体平移,模拟「这份数据其实来自另一份编号完全不同的备份」。
//
// 这不是臆造的场景:rond 每次导出/同步都会重排 Z_PK,所以「把本地和后来新增的备份合并」
// 时,两边编号一定对不上,唯一稳定的键只有到访 `ZIDENTIFIER_` / 活动 `ZUID_`。
// 夹具导入的就是 ROND_SRC 本身,编号与备份逐条相同(实测 823/823),
// 所以不平移的话,比对会走「编号直接命中的快路径」,UUID 对齐这条真正在用的路径反而测不到。
//
// 平移量取得足够大(远超库里最大编号),单条 UPDATE 就不会在语句中途撞上唯一约束;
// 同时把指向到访编号的外键(movements.from/to_visit_src、weather.visit_src)和留档一起挪。
func (f *movementFixture) shiftSrcPKs(t *testing.T, delta int) {
t.Helper()
for _, q := range []string{
`UPDATE visits SET src_pk = src_pk + $2 WHERE dataset_id=$1`,
`UPDATE places SET src_pk = src_pk + $2 WHERE dataset_id=$1`,
`UPDATE activities SET src_pk = src_pk + $2 WHERE dataset_id=$1`,
`UPDATE movements SET src_pk = src_pk + $2,
from_visit_src = from_visit_src + $2, to_visit_src = to_visit_src + $2 WHERE dataset_id=$1`,
`UPDATE weather SET src_pk = src_pk + $2, visit_src = visit_src + $2 WHERE dataset_id=$1`,
`UPDATE entity_raw SET src_pk = src_pk + $2 WHERE dataset_id=$1`,
} {
if _, err := f.pg.ExecContext(f.ctx, q, f.dsID, delta); err != nil {
t.Fatalf("平移编号失败: %v\n%s", err, q)
}
}
}
// 同一份备份再传一次:UUID 对得上、字段也没变,到访应当一条不差。
//
// ⚠️ 注意这条用例**没有**验证「按 UUID 对齐」:夹具导入的就是 f.src 本身,
// 库里的 src_pk 与备份的 Z_PK 逐条相同(实测 823/823),它是靠编号直接命中的。
// 真正钉住 UUID 对齐的是 TestBackupDiffAlignsByUUIDNotSrcPK / TestBackupMergeRenumberedBackup。
//
// 只断言到访,不断言「完全一致」整句:导入后站点会反查补全地点(改名称/地址),
// 地点层面因此真的存在差异,那是预期行为,不该由这个用例来管。
//
// 但**「备份新增」必须是 0**:导入就是把备份的地点一条一条写进去的(含同名同坐标的重复项),
// 比对再认不出它们就会每次都判「多出来一条」⇒ 合并时反复插重复地点。真机备份里
// 「柳州站」在同一个坐标上有 3 条,正是这个形状。
func TestBackupDiffIdentical(t *testing.T) {
f := newMovementFixture(t)
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)
}
}
// 编号全被平移后(= 数据来自另一份重排过 Z_PK 的备份),同一份内容再比一次仍须零差异。
//
// 这是「本地 + 后来新增的备份」合并的真正前提:rond 每次导出都重排编号,
// 若比对退回按编号对齐,这里会报成「几百条备份新增 + 几百条仅站内有」,
// 勾一下「并进来」就会把整份足迹**复制一遍**。
func TestBackupDiffAlignsByUUIDNotSrcPK(t *testing.T) {
f := newMovementFixture(t)
f.shiftSrcPKs(t, 500000)
page := previewBackup(t, f, f.src)
if a, g, c := visitCounts(t, page); a != 0 || g != 0 || c != 0 {
t.Fatalf("编号平移后内容其实没变,差异应为 0;实际 新增 %d 仅站内有 %d 冲突 %d"+
" ⇒ 比对是靠编号对齐的,跨备份合并会把足迹复制一遍", a, g, c)
}
}
// 站内改过备注、又补录了一条到访,差异页要把这两件事分别认出来。
//
// 这里的关键是「按 UUID 对齐」:库里那些行的 Z_PK 与备份里完全不同
// (rond 重排过),只按编号比会把 800 多条全判成冲突。
func TestBackupDiffDetectsSiteEdits(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)
}
if _, err := f.pg.ExecContext(f.ctx, `INSERT INTO visits
(dataset_id, src_pk, arrival, departure, is_user_added, remark)
VALUES ($1, 999999, now(), now(), true, '站点补录的一条')`, f.dsID); err != nil {
t.Fatalf("插入补录到访失败: %v", err)
}
page := previewBackup(t, f, f.src)
// 「站点改过的备注」只可能从冲突行的字段差异里渲染出来;「站内补录」是那条补录行的来源标记
for _, want := range []string{"备份差异预览", "站点改过的备注", "站点补录的一条", ">站内补录<"} {
if !strings.Contains(page, want) {
t.Errorf("差异页缺少 %q", want)
}
}
a, g, c := visitCounts(t, page)
if c != 1 {
t.Errorf("改过一条备注,到访冲突数应为 1,实际 %d", c)
}
if g != 1 {
t.Errorf("补录了一条,到访「仅站内有」应为 1,实际 %d", g)
}
if a != 0 {
t.Errorf("备份里没有多出来的到访,实际 %d", a)
}
}
// 差异页是只读的:渲染前后库里的行数必须一模一样。
func TestBackupDiffPreviewWritesNothing(t *testing.T) {
f := newMovementFixture(t)
count := func() (visits, places, raw int) {
t.Helper()
if err := f.pg.QueryRowContext(f.ctx, `SELECT
(SELECT count(*) FROM visits WHERE dataset_id=$1),
(SELECT count(*) FROM places WHERE dataset_id=$1),
(SELECT count(*) FROM entity_raw WHERE dataset_id=$1)`, f.dsID).
Scan(&visits, &places, &raw); err != nil {
t.Fatalf("统计失败: %v", err)
}
return
}
v0, p0, r0 := count()
previewBackup(t, f, f.src)
v1, p1, r1 := count()
if v0 != v1 || p0 != p1 || r0 != r1 {
t.Fatalf("差异页不该写库: 到访 %d→%d 地点 %d→%d 留档 %d→%d", v0, v1, p0, p1, r0, r1)
}
}