Files
rond-with-you/internal/web/baseline_swap_test.go
T
tamakyi 52a82631da 换基线:说清「新底座里多出来的到访不会进导出包」,并把它钉成用例
写文案时去核这条,先把一条写错的断言纠正了:导出条数本来就不等于站点条数。

tableWriter.finish() 是两段:先删「基线里有、PG 已没有」的行(known − seen),
再把「导入时进不了 PG 但留档还在」的行(w.skipped × raws)按原样补回——否则
「导入→导出」一趟会凭空少数据。实测 823 条到访的数据集导出包里是 824 条,差值正常。
(第一版成功路径用例断言「导出 == 站点」,红在 824 != 823 就是这个原因;已改成更锋利的
「换底座前后导出包里 ZVISIT 的 UUID 集合逐条一致」。)

同一段代码定下了换基线的语义:新底座里站点没有的行会被清掉(进 known 没进 seen)。
所以换一份更新的手机包**不会**把手机新记录带进导出包,要带得先走「分析差异」合并。
页面提示与 flash 文案都写明了这点,并新增用例
TestSwapBaselineDoesNotCarryRowsTheSiteDoesNotHave 钉住它(站内补一条到访 → 导成包 →
从站内删掉 → 换底座 → 断言导出里既没有那条的派生 UUID、UUID 集合也与换之前逐条一致)。

验收:./scripts/verify.sh 全量 PASS=319 FAIL=0 SKIP=0。
2026-09-30 19:15:48 +08:00

562 lines
21 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package web
import (
"bytes"
"crypto/sha256"
"database/sql"
"encoding/base64"
"encoding/hex"
"errors"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"rond-with-you/internal/ingest"
"rond-with-you/internal/rond"
)
// ---------- 换基线 ----------
//
// 这些用例守的是「底座换代」这件事的几条底线:
// 站点数据一行不动、底座文件真的换了、旧底座留档可回滚、记档能撑住重建、
// 以及**不能把一份不相干的包换进来**(那是唯一会让导出包整个错位的操作)。
func baselineSHA(t *testing.T, path string) string {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
t.Fatalf("读 %s 失败: %v", path, err)
}
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:])
}
// baselinePackageFile 把一份包写到临时文件,当作「新底座」用。
// 传当前数据集的导出结果最贴近真实情形:它与原始包不是同一个文件(编号、WAL、私有列都不同),
// 但到访 UUID 与站点完全一致。
func baselinePackageFile(t *testing.T, name string, blob []byte) (path, sha string, size int64) {
t.Helper()
p := filepath.Join(t.TempDir(), name)
if err := os.WriteFile(p, blob, 0o644); err != nil {
t.Fatalf("写 %s 失败: %v", p, err)
}
sha, size, err := ingest.File(p)
if err != nil {
t.Fatalf("算摘要失败: %v", err)
}
return p, sha, size
}
// exportVisitUUIDs 读导出包里全部到访的身份(小写 hex),排序后返回。
// 这是「换底座有没有把行搬到别的编号空间」最锋利的判据:编号会换、身份不该换。
func exportVisitUUIDs(t *testing.T, edb *sql.DB) []string {
t.Helper()
rows, err := edb.Query(`SELECT COALESCE(hex(ZIDENTIFIER_),'') FROM ZVISIT ORDER BY 1`)
if err != nil {
t.Fatalf("读导出包的到访身份失败: %v", err)
}
defer rows.Close()
var out []string
for rows.Next() {
var s string
if err := rows.Scan(&s); err != nil {
t.Fatalf("读导出包的到访身份失败: %v", err)
}
out = append(out, s)
}
if err := rows.Err(); err != nil {
t.Fatalf("读导出包的到访身份失败: %v", err)
}
return out
}
func sameStrings(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// siteCounts 数站点的到访与地点,用来断言「换底座不动数据」。
func siteCounts(t *testing.T, f *movementFixture) (visits, places 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)`, f.dsID).Scan(&visits, &places); err != nil {
t.Fatalf("数站点数据失败: %v", err)
}
return
}
// baselineRecord 读换基线的记档。
func baselineRecord(t *testing.T, f *movementFixture) (name, sha string, at sql.NullTime) {
t.Helper()
if err := f.pg.QueryRowContext(f.ctx, `SELECT COALESCE(baseline_name,''), COALESCE(baseline_sha256,''), baseline_at
FROM datasets WHERE id=$1`, f.dsID).Scan(&name, &sha, &at); err != nil {
t.Fatalf("读记档失败: %v", err)
}
return
}
// scrambleVisitUUIDs 把站内到访留档里的 UUID 换成同一组假值:换完之后,
// 任何真实包里都找不到这些身份。
//
// 只动留档(entity_raw),不动 visits 表——到访本身还在,只是「认不出身份」了。
// 这正是「拿一份别的数据集的包来换底座」时站内看到的样子。
func scrambleVisitUUIDs(t *testing.T, f *movementFixture, only int) {
t.Helper()
fake := base64.StdEncoding.EncodeToString([]byte("0123456789abcdef"))
q := `UPDATE entity_raw SET raw = jsonb_set(raw, '{ZIDENTIFIER_,$blob}', to_jsonb($2::text))
WHERE dataset_id=$1 AND entity='ZVISIT' AND raw->'ZIDENTIFIER_' IS NOT NULL`
if only > 0 {
q += ` AND src_pk IN (SELECT src_pk FROM entity_raw WHERE dataset_id=$1 AND entity='ZVISIT'
AND raw->'ZIDENTIFIER_' IS NOT NULL ORDER BY src_pk LIMIT $3)`
if _, err := f.pg.ExecContext(f.ctx, q, f.dsID, fake, only); err != nil {
t.Fatalf("改写留档 UUID 失败: %v", err)
}
return
}
if _, err := f.pg.ExecContext(f.ctx, q, f.dsID, fake); err != nil {
t.Fatalf("改写留档 UUID 失败: %v", err)
}
}
// TestSwapBaselineRebasesExportAndKeepsData 换底座成功路径:
// 底座文件真的换了、旧底座留档、记档写入、站点数据一行不动、导出内容逐条不变。
func TestSwapBaselineRebasesExportAndKeepsData(t *testing.T) {
f := newMovementFixture(t)
ctx, dsID, srv := f.ctx, f.dsID, f.srv
if _, err := srv.ensureBaseline(ctx, dsID); err != nil {
t.Fatalf("准备底座失败: %v", err)
}
base := srv.baselinePath(dsID)
oldSHA := baselineSHA(t, base)
visitsBefore, placesBefore := siteCounts(t, f)
var arrivalBefore time.Time
if err := f.pg.QueryRowContext(ctx, `SELECT arrival FROM visits WHERE dataset_id=$1
ORDER BY src_pk LIMIT 1`, dsID).Scan(&arrivalBefore); err != nil {
t.Fatalf("取样本到访失败: %v", err)
}
// 拿「换底座之前导出的那一份包」当新底座。先记下它的内容,换完再对一遍。
blob := f.export(t)
edbBefore := openExport(t, blob)
visitsInExport := countExportAll(t, edbBefore)
uuidsBefore := exportVisitUUIDs(t, edbBefore)
pkg, sha, _ := baselinePackageFile(t, "new-base.rondbackup", blob)
rep, err := srv.swapBaseline(ctx, dsID, pkg, "new-base.rondbackup", sha, false)
if err != nil {
t.Fatalf("换底座失败: %v", err)
}
if rep.WithUUID < minOverlapVisits {
t.Fatalf("样本量太小(%d 条带 UUID 的到访),用例前提不成立", rep.WithUUID)
}
// 同一批数据的包应当条条对得上:对不上就说明这条判据本身有问题
if rep.Matched != rep.WithUUID {
t.Errorf("同一批数据的包应当全部对上,实际 %d/%d", rep.Matched, rep.WithUUID)
}
if !rep.Replaced {
t.Error("原本已有底座,应当报告为「替换」而不是「首次建立」")
}
// ① 底座文件真的换了,旧的那份进了留档
if got := baselineSHA(t, base); got == oldSHA {
t.Error("底座文件没变,替换没生效")
}
if rep.Backup == "" {
t.Fatal("没有留档旧底座")
}
if got := baselineSHA(t, rep.Backup); got != oldSHA {
t.Errorf("留档的不是旧底座:%s != %s", got, oldSHA)
}
// ② 记档
name, recSHA, at := baselineRecord(t, f)
if name != "new-base.rondbackup" || recSHA != sha || !at.Valid {
t.Errorf("记档不对:name=%q sha=%q at=%v", name, recSHA, at)
}
// ③ 站点数据一行没动
visitsAfter, placesAfter := siteCounts(t, f)
if visitsAfter != visitsBefore || placesAfter != placesBefore {
t.Errorf("换底座动了站点数据:到访 %d→%d、地点 %d→%d",
visitsBefore, visitsAfter, placesBefore, placesAfter)
}
var arrivalAfter time.Time
if err := f.pg.QueryRowContext(ctx, `SELECT arrival FROM visits WHERE dataset_id=$1
ORDER BY src_pk LIMIT 1`, dsID).Scan(&arrivalAfter); err != nil {
t.Fatalf("取样本到访失败: %v", err)
}
if !arrivalAfter.Equal(arrivalBefore) {
t.Errorf("样本到访的到达时间被改了:%v → %v", arrivalBefore, arrivalAfter)
}
// ④ 导出的内容逐条不变:换了编号空间,身份(UUID)不该动。
// 只要有一行在新底座里认不出身份,它就会被按「新行」补一个派生 UUID 插到最大编号之后,
// 这里立刻会亮。
edbAfter := openExport(t, f.export(t))
if n := countExportAll(t, edbAfter); n != visitsInExport {
t.Errorf("换底座改变了导出包的到访数:%d → %d", visitsInExport, n)
}
if got := exportVisitUUIDs(t, edbAfter); !sameStrings(got, uuidsBefore) {
t.Errorf("换底座改变了导出包里到访的身份(%d 条 → %d 条),说明有行在新底座里没对上",
len(uuidsBefore), len(got))
}
}
// TestEnsureBaselinePrefersRecordedPackage 底座文件丢了要按记档重建。
//
// 这是换底座必须配的一环:datasets.sha256 指的是**最初**那份包,
// 只认它的话,底座一丢就会被悄悄重建回旧包——站长上一次换基线白做,且毫无提示。
func TestEnsureBaselinePrefersRecordedPackage(t *testing.T) {
f := newMovementFixture(t)
ctx, dsID, srv := f.ctx, f.dsID, f.srv
if _, err := srv.ensureBaseline(ctx, dsID); err != nil {
t.Fatalf("准备底座失败: %v", err)
}
base := srv.baselinePath(dsID)
pkg, sha, size := baselinePackageFile(t, "new-base.rondbackup", f.export(t))
// 换底座时上传件会收进 uploads,重建就靠它
if _, err := srv.stashUpload(pkg, "new-base.rondbackup", sha, size); err != nil {
t.Fatalf("收下上传件失败: %v", err)
}
stored, err := srv.findUploadBySha(sha, 0)
if err != nil {
t.Fatalf("收下的包找不回来: %v", err)
}
t.Cleanup(func() { os.Remove(stored) })
if _, err := srv.swapBaseline(ctx, dsID, stored, "new-base.rondbackup", sha, false); err != nil {
t.Fatalf("换底座失败: %v", err)
}
// 把「最初那份包」的摘要指向一个不存在的值,逼出「按 datasets.sha256 找原始包」必然失败
if _, err := f.pg.ExecContext(ctx, `UPDATE datasets SET sha256=$2 WHERE id=$1`,
dsID, strings.Repeat("0", 64)); err != nil {
t.Fatalf("改写数据集摘要失败: %v", err)
}
if err := os.Remove(base); err != nil {
t.Fatalf("删底座失败: %v", err)
}
if _, err := srv.ensureBaseline(ctx, dsID); err != nil {
t.Fatalf("应当按记档的那份包重建底座,实际报错: %v", err)
}
if _, err := os.Stat(base); err != nil {
t.Error("底座没有被重建")
}
// 反证:把记档也清掉,同一条路就走不通了 —— 说明上面那次成功确实是记档起的作用,
// 不是别的兜底把用例弄绿了。
if _, err := f.pg.ExecContext(ctx, `UPDATE datasets SET baseline_sha256=NULL, baseline_name=NULL
WHERE id=$1`, dsID); err != nil {
t.Fatalf("清记档失败: %v", err)
}
if err := os.Remove(base); err != nil {
t.Fatalf("删底座失败: %v", err)
}
if _, err := srv.ensureBaseline(ctx, dsID); err == nil {
t.Error("记档清掉后不该还能重建(原始包已不可用)")
}
}
// TestSwapBaselineAsksBeforePartialMismatch 重合率偏低时先问一句;
// 站长点了确认(force)才放行。
func TestSwapBaselineAsksBeforePartialMismatch(t *testing.T) {
f := newMovementFixture(t)
ctx, dsID, srv := f.ctx, f.dsID, f.srv
if _, err := srv.ensureBaseline(ctx, dsID); err != nil {
t.Fatalf("准备底座失败: %v", err)
}
base := srv.baselinePath(dsID)
// 先导出(此时包里的 UUID 还是真的),再把大部分站内到访的身份改掉
pkg, sha, _ := baselinePackageFile(t, "partial.rondbackup", f.export(t))
visitsBefore, _ := siteCounts(t, f)
scrambleVisitUUIDs(t, f, visitsBefore*7/10)
oldSHA := baselineSHA(t, base)
_, err := srv.swapBaseline(ctx, dsID, pkg, "partial.rondbackup", sha, false)
var mism *errBaselineMismatch
if err == nil {
t.Fatal("重合率不到一半,不该直接放行")
}
if !errors.As(err, &mism) || mism.zero {
t.Fatalf("应当是「需确认」而不该是「一条都对不上」: %v", err)
}
if baselineSHA(t, base) != oldSHA {
t.Error("被拦下了却已经把底座换了")
}
if _, recSHA, _ := baselineRecord(t, f); recSHA != "" {
t.Error("被拦下了却写了记档")
}
// 站长确认后放行
rep, err := srv.swapBaseline(ctx, dsID, pkg, "partial.rondbackup", sha, true)
if err != nil {
t.Fatalf("确认后仍无法替换: %v", err)
}
if rep.Replaced && baselineSHA(t, base) == oldSHA {
t.Error("确认后底座文件仍然没变")
}
if _, recSHA, _ := baselineRecord(t, f); recSHA != sha {
t.Error("确认替换后没写记档")
}
}
// TestSwapBaselineRefusesTotallyUnrelated 一条都对不上时,连「强制」都不给过。
// 这种情况下换下去没有任何一处能对齐,等于把整个导出包搬到另一个编号空间上。
func TestSwapBaselineRefusesTotallyUnrelated(t *testing.T) {
f := newMovementFixture(t)
ctx, dsID, srv := f.ctx, f.dsID, f.srv
if _, err := srv.ensureBaseline(ctx, dsID); err != nil {
t.Fatalf("准备底座失败: %v", err)
}
base := srv.baselinePath(dsID)
pkg, sha, _ := baselinePackageFile(t, "foreign.rondbackup", f.export(t))
scrambleVisitUUIDs(t, f, 0) // 全部
oldSHA := baselineSHA(t, base)
for _, force := range []bool{false, true} {
_, err := srv.swapBaseline(ctx, dsID, pkg, "foreign.rondbackup", sha, force)
var mism *errBaselineMismatch
if err == nil {
t.Fatalf("force=%v 时一份完全对不上的包也不该被接受", force)
}
if !errors.As(err, &mism) || !mism.zero {
t.Fatalf("force=%v 时应当判定为「一条都对不上」: %v", force, err)
}
}
if baselineSHA(t, base) != oldSHA {
t.Error("被拒绝却已经把底座换了")
}
if _, recSHA, _ := baselineRecord(t, f); recSHA != "" {
t.Error("被拒绝却写了记档")
}
}
// TestSwapBaselineRejectsNonPackage 不是备份包的文件要在第一步就被挡住:
// 生成底座失败 ⇒ 既不换文件也不写记档,数据集保持原样可用。
func TestSwapBaselineRejectsNonPackage(t *testing.T) {
f := newMovementFixture(t)
ctx, dsID, srv := f.ctx, f.dsID, f.srv
if _, err := srv.ensureBaseline(ctx, dsID); err != nil {
t.Fatalf("准备底座失败: %v", err)
}
base := srv.baselinePath(dsID)
oldSHA := baselineSHA(t, base)
junk := filepath.Join(t.TempDir(), "junk.rondbackup")
if err := os.WriteFile(junk, []byte("这不是备份包"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := srv.swapBaseline(ctx, dsID, junk, "junk.rondbackup", strings.Repeat("a", 64), true); err == nil {
t.Fatal("不是备份包却换成功了")
}
if baselineSHA(t, base) != oldSHA {
t.Error("被拒绝却已经把底座换了")
}
if _, recSHA, _ := baselineRecord(t, f); recSHA != "" {
t.Error("被拒绝却写了记档")
}
}
// TestRollbackBaselineRestoresPrevious 回滚要把旧底座换回来、记档清空,
// 并且当前这份也要留档(回滚本身也得可逆)。
func TestRollbackBaselineRestoresPrevious(t *testing.T) {
f := newMovementFixture(t)
ctx, dsID, srv := f.ctx, f.dsID, f.srv
if _, err := srv.ensureBaseline(ctx, dsID); err != nil {
t.Fatalf("准备底座失败: %v", err)
}
base := srv.baselinePath(dsID)
oldSHA := baselineSHA(t, base)
pkg, sha, _ := baselinePackageFile(t, "new-base.rondbackup", f.export(t))
if _, err := srv.swapBaseline(ctx, dsID, pkg, "new-base.rondbackup", sha, false); err != nil {
t.Fatalf("换底座失败: %v", err)
}
newSHA := baselineSHA(t, base)
if newSHA == oldSHA {
t.Fatal("换底座没生效,后续断言没有意义")
}
if _, err := srv.rollbackBaseline(ctx, dsID); err != nil {
t.Fatalf("回滚失败: %v", err)
}
if got := baselineSHA(t, base); got != oldSHA {
t.Errorf("回滚后底座不是原来那份:%s != %s", got, oldSHA)
}
if _, recSHA, at := baselineRecord(t, f); recSHA != "" || at.Valid {
t.Error("回滚后应当清空记档(表示来源是数据集最初那份包)")
}
// 被回滚掉的那份也要留档,否则滚错了就回不去
st, err := srv.baselineStatusOf(ctx, dsID)
if err != nil {
t.Fatalf("读底座状态失败: %v", err)
}
found := false
for _, b := range st.Backups {
if baselineSHA(t, filepath.Join(filepath.Dir(base), b.Name)) == newSHA {
found = true
}
}
if !found {
t.Error("回滚时没有把当前这份也留档,滚错了就回不去了")
}
}
// TestBaselineUploadHandlerSwapsAndRecords 走一次真实的「上传并换底座」处理函数:
// 303 回页面带提示、底座真的换了、上传件留在 uploads(以后底座丢了要按摘要找回它)。
func TestBaselineUploadHandlerSwapsAndRecords(t *testing.T) {
f := newMovementFixture(t)
ctx, dsID, srv := f.ctx, f.dsID, f.srv
if _, err := srv.ensureBaseline(ctx, dsID); err != nil {
t.Fatalf("准备底座失败: %v", err)
}
base := srv.baselinePath(dsID)
oldSHA := baselineSHA(t, base)
blob := f.export(t)
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
fw, err := mw.CreateFormFile("backup", "new-base.rondbackup")
if err != nil {
t.Fatal(err)
}
if _, err := fw.Write(blob); err != nil {
t.Fatal(err)
}
if err := mw.Close(); err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/admin/baseline", &buf).WithContext(ctx)
req.Header.Set("Content-Type", mw.FormDataContentType())
srv.adminBaselineUpload(rec, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("应当 303 回页面,实际 %d:%s", rec.Code, rec.Body.String())
}
loc := rec.Header().Get("Location")
if !strings.HasPrefix(loc, "/admin/baseline?ok=") {
t.Fatalf("应当带着成功提示回页面,实际 Location=%q", loc)
}
if baselineSHA(t, base) == oldSHA {
t.Error("处理函数跑完了但底座没换")
}
var stored string
if err := f.pg.QueryRowContext(ctx, `SELECT COALESCE(baseline_sha256,'') FROM datasets WHERE id=$1`, dsID).
Scan(&stored); err != nil {
t.Fatalf("读记档失败: %v", err)
}
if len(stored) != 64 {
t.Fatalf("记档里的摘要不对: %q", stored)
}
path, err := srv.findUploadBySha(stored, 0)
if err != nil {
t.Fatalf("上传的包没留在 uploads,底座丢了就重建不出来: %v", err)
}
t.Cleanup(func() { os.Remove(path) })
}
// TestBaselinePageRenders 页面能渲染到底(含尾部 </html>)。
//
// render 是流式直写,模板里的字段错(比如把 *time.Time 传给只认 time.Time 的函数)
// 会在写到一半时中断:HTTP 200 + 一个「页脚之后整段不存在」的页面,服务端一声不吭。
// 所以既要断言 200,也要断言动作之后的字和收尾标签。
func TestBaselinePageRenders(t *testing.T) {
f := newMovementFixture(t)
ctx, srv := f.ctx, f.srv
if _, err := srv.ensureBaseline(ctx, f.dsID); err != nil {
t.Fatalf("准备底座失败: %v", err)
}
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/admin/baseline", nil).WithContext(ctx)
srv.adminBaselinePage(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("页面状态码 %d:%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
for _, want := range []string{"当前底座", "换成另一份包当底座", "旧底座留档"} {
if !strings.Contains(body, want) {
t.Errorf("页面里没有「%s」", want)
}
}
if !strings.HasSuffix(strings.TrimSpace(body), "</html>") {
tail := body
if len(tail) > 200 {
tail = tail[len(tail)-200:]
}
t.Errorf("页面没渲染到底(尾部不是 </html>),多半是模板中途报错了:…%s", tail)
}
}
// TestSwapBaselineDoesNotCarryRowsTheSiteDoesNotHave 守一条反直觉的语义:
// 新底座里那些站点还没有的到访**不会**进导出包(导出的行集合以站点数据为准)。
//
// 站长很容易以为「换了更新的手机包 ⇒ 导出的包里就有手机上的新记录」,所以提示文案里
// 必须点出这一点;而这条语义本身也不能只靠文案保证——下面用真数据验一遍。
func TestSwapBaselineDoesNotCarryRowsTheSiteDoesNotHave(t *testing.T) {
f := newMovementFixture(t)
ctx, dsID, srv := f.ctx, f.dsID, f.srv
if _, err := srv.ensureBaseline(ctx, dsID); err != nil {
t.Fatalf("准备底座失败: %v", err)
}
uuidsBefore := exportVisitUUIDs(t, openExport(t, f.export(t)))
// 先在站内补一条到访(没有留档 ⇒ 导出时会补一个本站派生的确定性 UUID),
// 导出成一份「比站内多一条」的包,再把这条从站内删掉:新底座里就留下了一条
// 站点没有的行,正是要验证的情形。
seed := pickDupBase(t, srv, ctx, dsID)
extra, err := srv.nextSrcPK(ctx, f.pg, dsID, visitSeq)
if err != nil {
t.Fatal(err)
}
if err := clearStaleRaw(ctx, f.pg, dsID, "ZVISIT", extra); err != nil {
t.Fatal(err)
}
if _, err := f.pg.ExecContext(ctx, `INSERT INTO visits
(dataset_id, src_pk, place_id, activity_id, arrival, departure, duration_min)
SELECT dataset_id, $3, place_id, activity_id, arrival, departure, duration_min
FROM visits WHERE dataset_id=$1 AND src_pk=$2`, dsID, seed.srcPK, extra); err != nil {
t.Fatal(err)
}
pkg, sha, _ := baselinePackageFile(t, "newer.rondbackup", f.export(t))
if _, err := f.pg.ExecContext(ctx, `DELETE FROM visits WHERE dataset_id=$1 AND src_pk=$2`,
dsID, extra); err != nil {
t.Fatal(err)
}
rep, err := srv.swapBaseline(ctx, dsID, pkg, "newer.rondbackup", sha, false)
if err != nil {
t.Fatalf("换底座失败: %v", err)
}
if baselineCountValue(rep.Counts, "ZVISIT") <= rep.SiteVisits {
t.Fatalf("用例前提不成立:新底座应当比站内多出到访,实际底座 %d / 站内 %d",
baselineCountValue(rep.Counts, "ZVISIT"), rep.SiteVisits)
}
if !strings.Contains(baselineSwapMessage(rep, "数据集"), "不会进导出包") {
t.Error("提示里没有点出「底座里多出来的到访不会进导出包」")
}
after := exportVisitUUIDs(t, openExport(t, f.export(t)))
if !sameStrings(after, uuidsBefore) {
t.Errorf("换到「更新」的底座后导出内容变了(%d 条 → %d 条)", len(uuidsBefore), len(after))
}
want := rond.DerivedVisitUUID(extra)
for _, u := range after {
if u == want {
t.Errorf("那条只在新底座里的到访(%s)被带进了导出包", u)
}
}
}