Files
rond-with-you/internal/web/movement_e2e_test.go
T
2026-09-18 13:05:49 +08:00

684 lines
28 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 (
"context"
"database/sql"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
"rond-with-you/internal/auth"
"rond-with-you/internal/config"
"rond-with-you/internal/db"
"rond-with-you/internal/geo"
"rond-with-you/internal/ingest"
"rond-with-you/internal/rond"
)
// movementFixture 是一份「刚导入、还没被改过」的数据集,外加可直接调用的后台服务。
//
// 需要本地才有的测试库配置与原始备份包,用环境变量开启(缺一个就跳过):
//
// ROND_TEST_CONF=conf/app-test.ini ROND_SRC=data/xxx.rondbackup go test ./internal/web -run Export -v
//
// 它会在测试库里建一个专用账号,跑完连同它的数据集一起删掉,不碰别的数据。
type movementFixture struct {
cfg *config.Config
pg *sql.DB
srv *Server
dsID int64
src string // 原始 .rondbackup 的路径
ctx context.Context // 已注入登录态,可直接喂给后台处理函数
}
func newMovementFixture(t *testing.T) *movementFixture {
t.Helper()
confPath, src := os.Getenv("ROND_TEST_CONF"), os.Getenv("ROND_SRC")
if confPath == "" || src == "" {
t.Skip("设置 ROND_TEST_CONF 与 ROND_SRC 后运行导入导出校验")
}
// go test 的工作目录是包目录(internal/web),而配置里的相对路径、以及调试模式下
// 模板的磁盘路径(internal/web/templates/...)都是相对仓库根的,先切过去
chdirModuleRoot(t)
cfg, err := config.Load(confPath)
if err != nil {
t.Fatalf("加载配置 %s 失败: %v", confPath, err)
}
ctx := context.Background()
pg, err := db.Open(cfg.DSN)
if err != nil {
t.Fatalf("连接数据库失败: %v", err)
}
// 注册得比后面几项早,t.Cleanup 是后进先出,所以连接会最后才关
t.Cleanup(func() { pg.Close() })
if err := db.Migrate(ctx, pg); err != nil {
t.Fatalf("初始化数据库失败: %v", err)
}
// 用一次性账号隔离:导入去重是按 (user_id, sha256) 判的,共用账号会命中
// 上一次跑剩的数据集,测的就不是「刚导入的这份」了。
// 顺带清掉上次跑到一半留下的账号,免得越攒越多。
purgeE2EUsers(t, ctx, pg, cfg)
user := fmt.Sprintf("e2e-movement-%d", time.Now().UnixNano())
hash, err := auth.HashPassword("e2e-password")
if err != nil {
t.Fatalf("生成口令哈希失败: %v", err)
}
var uid int64
if err := pg.QueryRowContext(ctx, `INSERT INTO users (username, password_hash, display_name)
VALUES ($1,$2,'e2e') RETURNING id`, user, hash).Scan(&uid); err != nil {
t.Fatalf("创建测试账号失败: %v", err)
}
var prevActive int64
pg.QueryRowContext(ctx, `SELECT COALESCE(id,0) FROM datasets WHERE is_active LIMIT 1`).Scan(&prevActive)
t.Cleanup(func() {
dropE2EUser(t, ctx, pg, cfg, uid)
if prevActive > 0 {
pg.ExecContext(ctx, `UPDATE datasets SET is_active=(id=$1) WHERE id=$1`, prevActive)
}
})
// 导入原始备份包
rdb, err := rond.Open(src, t.TempDir())
if err != nil {
t.Fatalf("打开 %s 失败: %v", src, err)
}
defer rdb.Close()
backup, err := rond.Parse(rdb)
if err != nil {
t.Fatalf("解析 %s 失败: %v", src, err)
}
sha, size, err := ingest.File(src)
if err != nil {
t.Fatalf("计算摘要失败: %v", err)
}
dsID, _, err := ingest.Import(ctx, pg, uid, filepath.Base(src), sha, size, backup)
if err != nil {
t.Fatalf("导入失败: %v", err)
}
// 导出取基线库时要按 sha 在 uploads 目录里找回原包,这里补一份
if _, err := os.Stat(cfg.UploadDir); errors.Is(err, os.ErrNotExist) {
os.MkdirAll(cfg.UploadDir, 0o755)
}
archived := filepath.Join(cfg.UploadDir, fmt.Sprintf("e2e-%d-%s", time.Now().Unix(), filepath.Base(src)))
if err := copyTo(archived, src); err != nil {
t.Fatalf("复制原始包到上传目录失败: %v", err)
}
t.Cleanup(func() { os.Remove(archived) })
srv, err := New(cfg, pg)
if err != nil {
t.Fatalf("初始化服务失败: %v", err)
}
return &movementFixture{cfg: cfg, pg: pg, srv: srv, dsID: dsID, src: src,
ctx: ctxWithUser(&auth.User{ID: uid, Username: user})}
}
// openSource 再开一次原始包,用来跟导出结果做对照。
func (f *movementFixture) openSource(t *testing.T) *sql.DB {
t.Helper()
db, err := rond.Open(f.src, t.TempDir())
if err != nil {
t.Fatalf("打开原始包失败: %v", err)
}
t.Cleanup(func() { db.Close() })
return db
}
// export 走后台真实的导出处理函数,返回 .rondbackup 的字节。
func (f *movementFixture) export(t *testing.T) []byte {
t.Helper()
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/admin/export/rondbackup", nil).WithContext(f.ctx)
f.srv.exportRondbackup(rec, req)
if rec.Code != 200 {
t.Fatalf("导出失败: code=%d body=%s", rec.Code, rec.Body.String())
}
return rec.Body.Bytes()
}
// TestMovementCRUDExportE2E 端到端验证「后台增改删行程 → 导出 rondbackup」这条链路。
//
// 真的导入、真的调用后台处理函数、真的导出,然后直接读导出的 SQLite 逐项核对:
// 新建/修改的位移有没有带上 rond 必需的列(ZTYPE_ 等)、派生字段是否落库、
// 删掉的是否真没了、以及没被碰过的数据是不是一字未动。
func TestMovementCRUDExportE2E(t *testing.T) {
f := newMovementFixture(t)
ctx, pg, dsID := f.ctx, f.pg, f.dsID
srv, base := f.srv, f.ctx
// 选一对合法的起止到访:起点有离开时间,终点到访在其之后
var fromVisit, toVisit int
if err := pg.QueryRowContext(ctx, `SELECT vf.src_pk, vt.src_pk
FROM visits vf JOIN visits vt ON vt.dataset_id=vf.dataset_id AND vt.arrival > vf.departure + interval '30 min'
WHERE vf.dataset_id=$1 AND vf.departure IS NOT NULL
ORDER BY vf.arrival DESC LIMIT 1`, dsID).Scan(&fromVisit, &toVisit); err != nil {
t.Fatalf("挑不出合法的起止到访: %v", err)
}
var wantStart, wantEnd time.Time
if err := pg.QueryRowContext(ctx, `SELECT vf.departure, vt.arrival FROM visits vf JOIN visits vt
ON vt.dataset_id=vf.dataset_id AND vt.src_pk=$3
WHERE vf.dataset_id=$1 AND vf.src_pk=$2`, dsID, fromVisit, toVisit).Scan(&wantStart, &wantEnd); err != nil {
t.Fatalf("读取两端到访时间失败: %v", err)
}
t.Logf("起止到访 #%d → #%d,合法区间 %v ~ %v", fromVisit, toVisit, wantStart.Local(), wantEnd.Local())
// 3) 编号复用防护:先删掉基线里编号最大的那条位移,再新建。
// 若按「库内 MAX(src_pk)+1」分配,这里会原样拿回 791——那个号在基线里已存在,
// 导出时新行会被当成旧行去 UPDATE,ZTYPE_ 之类就继承成别人的值。
var baselineMax int
if err := pg.QueryRowContext(ctx, `SELECT COALESCE(MAX(src_pk),0) FROM movements WHERE dataset_id=$1`,
dsID).Scan(&baselineMax); err != nil {
t.Fatal(err)
}
post(t, srv.editMovementDelete, base, "/admin/edit/movement/delete", url.Values{
"src_pk": {fmt.Sprint(baselineMax)},
})
post(t, srv.editMovementCreate, base, "/admin/edit/movement", url.Values{
"from_visit": {fmt.Sprint(fromVisit)},
"to_visit": {fmt.Sprint(toVisit)},
})
plainMove, _ := lastMovement(t, ctx, pg, dsID)
if plainMove <= baselineMax {
t.Errorf("删掉 #%d 后新建的行程拿到了 #%d:复用了基线里的编号", baselineMax, plainMove)
}
t.Logf("删掉基线里最大的位移 #%d 后新建得到 #%d", baselineMax, plainMove)
// 4) 新建:时间留空(应自动推断)、顺带新建一个交通方式
post(t, srv.editMovementCreate, base, "/admin/edit/movement", url.Values{
"from_visit": {fmt.Sprint(fromVisit)},
"to_visit": {fmt.Sprint(toVisit)},
"transport_src": {transportNewValue},
"transport_name": {"共享单车"},
"transport_color": {"green"},
"transport_icon": {"bicycle"},
})
newMove, newTrans := lastMovement(t, ctx, pg, dsID)
if newTrans <= 0 {
t.Fatal("新建交通方式没有分配编号")
}
t.Logf("新建行程 src_pk=%d,交通方式 #%d", newMove, newTrans)
// 5) 改:把新建那条的时间改成区间内的一小段,并把方式改名。
// 表单是分钟精度,断言也要按分钟对齐,否则差的就是被截掉的秒数
mid := wantStart.Add(wantEnd.Sub(wantStart) / 3).Truncate(time.Minute)
post(t, srv.editMovementUpdate, base, "/admin/edit/movement/update", url.Values{
"src_pk": {fmt.Sprint(newMove)},
"from_visit": {fmt.Sprint(fromVisit)},
"to_visit": {fmt.Sprint(toVisit)},
"start": {mid.Format("2006-01-02T15:04")},
"end": {mid.Add(10 * time.Minute).Format("2006-01-02T15:04")},
"transport_src": {fmt.Sprint(newTrans)},
"transport_name": {"单车"},
"transport_color": {"green"},
"transport_icon": {"bicycle"},
})
// 5b) 只改交通方式:时间必须原样不动。表单是分钟精度,回填后原样提交若被当成
// 「改了时间」写回,真机的小数秒就被抹掉了,而且截断后还早于起点到访的离开时间,
// 会被校验拒掉(线上就是「改方式提示开始时间不能早于离开时间」)。
var pickPK, pickTrans, otherTrans int
var pickFrom, pickTo int
var pickSt, pickEn time.Time
// 优先挑「把开始时间截到分钟就会早于起点到访的离开时间」的那类行——线上报的就是它
const pickCols = `SELECT m.src_pk, m.transport_src, m.started_at, m.ended_at,
m.from_visit_src, m.to_visit_src,
(SELECT min(transport_src) FROM movements WHERE dataset_id=m.dataset_id
AND transport_src IS NOT NULL AND transport_src <> m.transport_src)`
const pickWhere = ` FROM movements m
JOIN visits vf ON vf.dataset_id=m.dataset_id AND vf.src_pk=m.from_visit_src
WHERE m.dataset_id=$1 AND m.to_visit_src IS NOT NULL
AND m.transport_src IS NOT NULL AND m.started_at IS NOT NULL AND m.ended_at IS NOT NULL`
const pickTail = ` ORDER BY m.src_pk LIMIT 1`
err := pg.QueryRowContext(ctx, pickCols+pickWhere+` AND date_trunc('minute', m.started_at) < vf.departure`+pickTail, dsID).
Scan(&pickPK, &pickTrans, &pickSt, &pickEn, &pickFrom, &pickTo, &otherTrans)
if errors.Is(err, sql.ErrNoRows) {
err = pg.QueryRowContext(ctx, pickCols+pickWhere+pickTail, dsID).
Scan(&pickPK, &pickTrans, &pickSt, &pickEn, &pickFrom, &pickTo, &otherTrans)
}
if err != nil {
t.Fatalf("挑不出可改方式的已有行程: %v", err)
}
var otherName, otherColor string
pg.QueryRowContext(ctx, `SELECT COALESCE(max(transport_name),''), COALESCE(max(transport_color),'')
FROM movements WHERE dataset_id=$1 AND transport_src=$2`, dsID, otherTrans).Scan(&otherName, &otherColor)
post(t, srv.editMovementUpdate, base, "/admin/edit/movement/update", url.Values{
"src_pk": {fmt.Sprint(pickPK)},
"from_visit": {fmt.Sprint(pickFrom)},
"to_visit": {fmt.Sprint(pickTo)},
"start": {pickSt.Local().Format("2006-01-02T15:04")},
"end": {pickEn.Local().Format("2006-01-02T15:04")},
"transport_src": {fmt.Sprint(otherTrans)},
"transport_name": {otherName},
"transport_color": {otherColor},
})
var gotSt, gotEn time.Time
var gotTrans int
if err := pg.QueryRowContext(ctx, `SELECT started_at, ended_at, transport_src FROM movements
WHERE dataset_id=$1 AND src_pk=$2`, dsID, pickPK).Scan(&gotSt, &gotEn, &gotTrans); err != nil {
t.Fatal(err)
}
if !gotSt.Equal(pickSt) || !gotEn.Equal(pickEn) {
t.Errorf("只改方式却动了时间: %v~%v → %v~%v", pickSt, pickEn, gotSt, gotEn)
}
if gotTrans != otherTrans {
t.Errorf("交通方式没改成: 期望 %d,实际 %d", otherTrans, gotTrans)
}
t.Logf("只改方式 #%d:%d→%d,时间保持 %v ~ %v", pickPK, pickTrans, gotTrans, gotSt, gotEn)
// 5c) 坐标换算:真机里有一批点的显示坐标其实没做过 GCJ-02 偏移(留档里的原始
// GPS 与显示坐标一字不差)。把它们转成 GCJ-02 后,导出包里
// 「gcj(原始GPS) == 显示坐标」这条不变量必须重新成立,否则 rond 上还是偏的。
var susPK int
var susLat, susLon float64
var susName, susCat, susProv, susCity, susDist string
err = pg.QueryRowContext(ctx, `SELECT p.src_pk, p.lat, p.lon, COALESCE(p.name,''),
COALESCE(p.poi_category,''), COALESCE(p.province,''), COALESCE(p.city,''), COALESCE(p.district,'')
FROM places p`+placeRawJoin+` WHERE p.dataset_id=$1 AND `+suspectWGS+` ORDER BY p.src_pk LIMIT 1`,
dsID).Scan(&susPK, &susLat, &susLon, &susName, &susCat, &susProv, &susCity, &susDist)
if errors.Is(err, sql.ErrNoRows) {
t.Error("这份数据集里应当有「显示坐标其实是 WGS-84」的点,一个都没判出来")
} else if err != nil {
t.Fatalf("挑选待换算的地点失败: %v", err)
} else {
nlat, nlon := geo.WGS84ToGCJ02(susLat, susLon)
loc := post(t, srv.editPlaceUpdate, base, "/admin/edit/place/update", url.Values{
"src_pk": {fmt.Sprint(susPK)},
"name": {susName},
"category": {susCat},
"lat": {fmt.Sprintf("%.6f", nlat)},
"lon": {fmt.Sprintf("%.6f", nlon)},
"province": {susProv},
"city": {susCity},
"district": {susDist},
})
if !strings.Contains(loc, "err=") && !strings.Contains(loc, url.QueryEscape("坐标移动")) {
t.Errorf("保存后应提示坐标移动了多少,实际跳转: %s", loc)
}
var gotLat, gotLon float64
pg.QueryRowContext(ctx, `SELECT lat, lon FROM places WHERE dataset_id=$1 AND src_pk=$2`,
dsID, susPK).Scan(&gotLat, &gotLon)
if absFloat(gotLat-nlat) > 1e-6 || absFloat(gotLon-nlon) > 1e-6 {
t.Errorf("坐标没写进去: %.6f,%.6f(期望 %.6f,%.6f)", gotLat, gotLon, nlat, nlon)
}
var still int
pg.QueryRowContext(ctx, `SELECT count(*) FROM places p`+placeRawJoin+
` WHERE p.dataset_id=$1 AND p.src_pk=$2 AND `+suspectWGS, dsID, susPK).Scan(&still)
if still != 0 {
t.Error("换算过的点仍被判为「疑似 WGS-84」")
}
t.Logf("坐标换算 #%d %s:%.6f,%.6f → %.6f,%.6f", susPK, susName, susLat, susLon, nlat, nlon)
}
// 5d) 站点新建的地点(照片导入就是这一类,没有 rond 留档):换算后导出时要把
// 「换算前用户填的那个值」当成原始 GPS 写进 ZRAWLATITUDE/ZRAWLONGITUDE。
// 新行的 ZRAW 是导出时按显示坐标反算的,所以换算一次正好把它还原成真值——
// rond 里位置对了,原始 GPS 也留着。
const siteLat, siteLon = 22.853216, 113.253067
post(t, srv.editPlaceCreate, base, "/admin/edit/place", url.Values{
"mode": {"new"}, "name": {"e2e-坐标换算"}, "lat": {fmt.Sprint(siteLat)}, "lon": {fmt.Sprint(siteLon)},
})
var sitePK int
if err := pg.QueryRowContext(ctx, `SELECT src_pk FROM places WHERE dataset_id=$1 ORDER BY id DESC LIMIT 1`,
dsID).Scan(&sitePK); err != nil {
t.Fatalf("读取新建地点失败: %v", err)
}
siteConvLat, siteConvLon := geo.WGS84ToGCJ02(siteLat, siteLon)
post(t, srv.editPlaceUpdate, base, "/admin/edit/place/update", url.Values{
"src_pk": {fmt.Sprint(sitePK)},
"name": {"e2e-坐标换算"},
"lat": {fmt.Sprintf("%.6f", siteConvLat)},
"lon": {fmt.Sprintf("%.6f", siteConvLon)},
})
t.Logf("站点新建地点 #%d 换算:%.6f,%.6f → %.6f,%.6f", sitePK, siteLat, siteLon, siteConvLat, siteConvLon)
// 6) 派生字段必须落库:网页地图靠 from/to_place_id 画线,访客侧的区域过滤也按它们判定,
// 留空的话这段行程会绕开黑名单区域直接露出来
var dist sql.NullFloat64
var fromID, toID, dur sql.NullInt64
if err := pg.QueryRowContext(ctx, `SELECT from_place_id, to_place_id, distance_km,
COALESCE(duration_min,0) FROM movements WHERE dataset_id=$1 AND src_pk=$2`,
dsID, newMove).Scan(&fromID, &toID, &dist, &dur); err != nil {
t.Fatalf("读取新建行程失败: %v", err)
}
if !dur.Valid || dur.Int64 != 10 {
t.Errorf("改后的时长应为 10 分钟,实际 %v", dur)
}
if !fromID.Valid || !toID.Valid {
t.Error("新建行程的 from_place_id / to_place_id 为空:地图轨迹与区域过滤都会失灵")
}
if !dist.Valid || dist.Float64 <= 0 {
t.Errorf("新建行程的里程没有算出来: %v", dist)
}
// 7) 编辑页要能渲染出来:行程列表的 SQL、交通方式聚合、模板执行都在这条路径上,
// 光测处理函数看不见它们
for _, q := range []string{"/admin/edit",
fmt.Sprintf("/admin/edit?mt=%d", newTrans),
fmt.Sprintf("/admin/edit?mp=%d", fromID.Int64),
"/admin/edit?mfrom=2026-09-01&mto=2026-09-30",
"/admin/edit?mt=none",
} {
rec := httptest.NewRecorder()
srv.editPage(rec, httptest.NewRequest("GET", q, nil).WithContext(base))
if rec.Code != 200 {
t.Fatalf("渲染 %s 失败: code=%d", q, rec.Code)
}
body := rec.Body.String()
for _, want := range []string{"行程记录", "新增行程", "新建方式…"} {
if !strings.Contains(body, want) {
t.Errorf("%s 的页面里没有 %q", q, want)
}
}
}
// 8) 导出
blob := f.export(t)
out := filepath.Join(t.TempDir(), "export.rondbackup")
if err := os.WriteFile(out, blob, 0o644); err != nil {
t.Fatal(err)
}
if p := os.Getenv("ROND_EXPORT_OUT"); p != "" {
os.WriteFile(p, blob, 0o644)
t.Logf("导出包已保存到 %s", p)
}
edb, err := rond.Open(out, t.TempDir())
if err != nil {
t.Fatalf("打开导出包失败: %v", err)
}
defer edb.Close()
// 9) 核对导出包
var mvType sql.NullInt64
var mvTrans sql.NullInt64
var mvStart, mvEnd sql.NullFloat64
var mvFrom, mvTo sql.NullInt64
if err := edb.QueryRow(`SELECT ZTYPE_, ZTRANSPORT_, ZSTART_, ZEND_, ZVISITFROM_, ZVISITTO_
FROM ZMOVEMENT WHERE Z_PK=?`, newMove).Scan(&mvType, &mvTrans, &mvStart, &mvEnd, &mvFrom, &mvTo); err != nil {
t.Fatalf("导出包里找不到新建的行程 #%d: %v", newMove, err)
}
if !mvType.Valid {
t.Error("新建行程的 ZTYPE_ 为空:真机 791/791 都有值,Core Data 里它是非可选属性")
} else if mvType.Int64 != 5 {
t.Errorf("有交通方式的新建行程 ZTYPE_ 应为 5,实际 %d", mvType.Int64)
}
if !mvTrans.Valid || int(mvTrans.Int64) != newTrans {
t.Errorf("新建行程的 ZTRANSPORT_ 应为 %d,实际 %v", newTrans, mvTrans)
}
if int(mvFrom.Int64) != fromVisit || int(mvTo.Int64) != toVisit {
t.Errorf("两端到访写回不对: %d/%d,应为 %d/%d", mvFrom.Int64, mvTo.Int64, fromVisit, toVisit)
}
if d := absFloat(mvStart.Float64 - appleSec(mid)); d > 1 {
t.Errorf("开始时间偏差 %.3f 秒(应为改后的 %v)", d, mid.Local())
}
if d := absFloat(mvEnd.Float64 - appleSec(mid.Add(10*time.Minute))); d > 1 {
t.Errorf("结束时间偏差 %.3f 秒", d)
}
var n int
edb.QueryRow(`SELECT count(*) FROM ZMOVEMENT WHERE Z_PK=?`, baselineMax).Scan(&n)
if n != 0 {
t.Errorf("删掉的位移 #%d 仍在导出包里", baselineMax)
}
// 无交通方式的新建行程 → ZTYPE_ 应为 2;这条同时验证了编号没有复用
// (复用了就会继承基线里那条记录的取值)
var plainType sql.NullInt64
if err := edb.QueryRow(`SELECT ZTYPE_ FROM ZMOVEMENT WHERE Z_PK=?`, plainMove).Scan(&plainType); err != nil {
t.Fatalf("导出包里找不到 #%d: %v", plainMove, err)
}
if !plainType.Valid || plainType.Int64 != 2 {
t.Errorf("无交通方式的新建行程 ZTYPE_ 应为 2,实际 %v", plainType)
}
// 交通方式:改名要作用到该方式的全部行程,导出后 ZTRANSPORT 只有一行这个名字
var tName, tColor string
if err := edb.QueryRow(`SELECT COALESCE(ZNAME_,''), COALESCE(ZCOLOR_,'') FROM ZTRANSPORT WHERE Z_PK=?`,
newTrans).Scan(&tName, &tColor); err != nil {
t.Fatalf("导出包里找不到新建的交通方式 #%d: %v", newTrans, err)
}
if tName != "单车" || tColor != "green" {
t.Errorf("交通方式应为「单车/green」,实际「%s/%s」", tName, tColor)
}
// 原有交通方式一个都不能少(删掉某方式的最后一条行程 ≠ 删除这个方式)
var transCount, unnamed int
edb.QueryRow(`SELECT count(*), COALESCE(sum(CASE WHEN COALESCE(ZNAME_,'')='' THEN 1 ELSE 0 END),0)
FROM ZTRANSPORT`).Scan(&transCount, &unnamed)
if transCount != 6 {
t.Errorf("ZTRANSPORT 应有 6 个(原 5 个 + 新建 1 个),实际 %d", transCount)
}
if unnamed != 0 {
t.Errorf("有 %d 个交通方式没有名字", unnamed)
}
var dup int
edb.QueryRow(`SELECT count(*) FROM (SELECT Z_PK FROM ZTRANSPORT GROUP BY Z_PK HAVING count(*)>1) x`).Scan(&dup)
if dup != 0 {
t.Error("ZTRANSPORT 出现重复主键")
}
// 全表 ZTYPE_ 非空
var total, missing int
edb.QueryRow(`SELECT count(*), COALESCE(sum(CASE WHEN ZTYPE_ IS NULL THEN 1 ELSE 0 END),0) FROM ZMOVEMENT`).
Scan(&total, &missing)
if missing > 0 {
t.Errorf("ZMOVEMENT 共 %d 条,其中 %d 条 ZTYPE_ 为空", total, missing)
}
// 只改方式的那条,导出后的 ZSTART_/ZEND_ 必须与原始包逐位一致(没被分钟精度截断)
srcDB := f.openSource(t)
var origSt, origEn float64
if err := srcDB.QueryRow(`SELECT ZSTART_, ZEND_ FROM ZMOVEMENT WHERE Z_PK=?`, pickPK).Scan(&origSt, &origEn); err != nil {
t.Fatalf("原始包里找不到 #%d: %v", pickPK, err)
}
var expSt, expEn sql.NullFloat64
if err := edb.QueryRow(`SELECT ZSTART_, ZEND_ FROM ZMOVEMENT WHERE Z_PK=?`, pickPK).Scan(&expSt, &expEn); err != nil {
t.Fatalf("导出包里找不到 #%d: %v", pickPK, err)
}
if d := absFloat(expSt.Float64 - origSt); d > 1e-6 {
t.Errorf("只改方式却把 ZSTART_ 改了 %.6f 秒(%.6f → %.6f)", d, origSt, expSt.Float64)
}
if d := absFloat(expEn.Float64 - origEn); d > 1e-6 {
t.Errorf("只改方式却把 ZEND_ 改了 %.6f 秒", d)
}
// 换算过的那个点:导出包里 gcj(原始GPS) 必须等于显示坐标(rond 的坐标模型),
// 且原始 GPS 本身不能被动过——它才是这条记录真正的 WGS-84。
if susPK > 0 {
var rawLat, rawLon, disLat, disLon float64
if err := edb.QueryRow(`SELECT ZRAWLATITUDE, ZRAWLONGITUDE, ZLATITUDE, ZLONGITUDE
FROM ZLOCATION WHERE Z_PK=?`, susPK).Scan(&rawLat, &rawLon, &disLat, &disLon); err != nil {
t.Fatalf("导出包里找不到换算过的地点 #%d: %v", susPK, err)
}
expLat, expLon := geo.WGS84ToGCJ02(rawLat, rawLon)
if absFloat(expLat-disLat) > 1e-6 || absFloat(expLon-disLon) > 1e-6 {
t.Errorf("导出包里 gcj(原始GPS) 与显示坐标对不上:gcj(%.6f,%.6f)=%.6f,%.6f vs %.6f,%.6f",
rawLat, rawLon, expLat, expLon, disLat, disLon)
}
if absFloat(rawLat-susLat) > 1e-9 {
t.Errorf("原始 GPS 被改动了: %.6f → %.6f", susLat, rawLat)
}
t.Logf("导出核对:%s 的 gcj(原始GPS)=%.6f,%.6f == 显示坐标 ✓", susName, expLat, expLon)
}
// 站点新建的那个地点:显示坐标是换算后的 GCJ-02,原始 GPS 应写回换算前用户填的值
{
var zrawLat, zrawLon, zdisLat, zdisLon float64
if err := edb.QueryRow(`SELECT ZRAWLATITUDE, ZRAWLONGITUDE, ZLATITUDE, ZLONGITUDE FROM ZLOCATION
WHERE Z_PK=?`, sitePK).Scan(&zrawLat, &zrawLon, &zdisLat, &zdisLon); err != nil {
t.Fatalf("导出包里找不到新建地点 #%d: %v", sitePK, err)
}
if absFloat(zdisLat-siteConvLat) > 1e-6 || absFloat(zdisLon-siteConvLon) > 1e-6 {
t.Errorf("新建地点的显示坐标应是换算后的值: %.6f,%.6f", zdisLat, zdisLon)
}
if absFloat(zrawLat-siteLat) > 1e-6 || absFloat(zrawLon-siteLon) > 1e-6 {
t.Errorf("新建地点的原始 GPS 应写回换算前输入的值 %.6f,%.6f,实际 %.6f,%.6f",
siteLat, siteLon, zrawLat, zrawLon)
}
if gLat, gLon := geo.WGS84ToGCJ02(zrawLat, zrawLon); absFloat(gLat-zdisLat) > 1e-6 || absFloat(gLon-zdisLon) > 1e-6 {
t.Errorf("gcj(原始GPS) 与显示坐标对不上: %.6f,%.6f", gLat, gLon)
}
t.Logf("新建地点导出核对:原始 GPS %.6f,%.6f / 显示坐标 %.6f,%.6f ✓", zrawLat, zrawLon, zdisLat, zdisLon)
}
// 未落库的实体必须原样带回来
var raw, rawAfter int
srcDB.QueryRow(`SELECT count(*) FROM ZRAWVISIT`).Scan(&raw)
edb.QueryRow(`SELECT count(*) FROM ZRAWVISIT`).Scan(&rawAfter)
if raw != rawAfter {
t.Errorf("ZRAWVISIT 行数变了: %d → %d", raw, rawAfter)
}
t.Logf("导出核对通过:位移 %d 条、交通方式 %d 个、ZRAWVISIT %d 条", total, transCount, rawAfter)
}
// TestMovementCleanExport 只做「导入后原样导出」,把包写到 ROND_CLEAN_OUT。
// 它是上面那个用例的对照组:一个没被改过的数据集导出来必须是纯净往返,
// 交给 tools/verify_rondbackup.py 逐字段比对,用来证明这轮改动没有波及别处。
func TestMovementCleanExport(t *testing.T) {
out := os.Getenv("ROND_CLEAN_OUT")
if out == "" {
t.Skip("设置 ROND_CLEAN_OUT 后导出一份纯净往返包")
}
f := newMovementFixture(t)
if err := os.WriteFile(out, f.export(t), 0o644); err != nil {
t.Fatal(err)
}
t.Logf("纯净往返包已保存到 %s", out)
}
// dropE2EUser 删掉测试账号:数据集表上的外键会级联清掉各事实表与整行留档,
// 留在磁盘上的基线库文件要自己删。
func dropE2EUser(t *testing.T, ctx context.Context, pg *sql.DB, cfg *config.Config, uid int64) {
t.Helper()
var ids []int64
rows, err := pg.QueryContext(ctx, `SELECT id FROM datasets WHERE user_id=$1`, uid)
if err != nil {
t.Logf("清理测试账号失败: %v", err)
return
}
for rows.Next() {
var id int64
rows.Scan(&id)
ids = append(ids, id)
}
rows.Close()
if _, err := pg.ExecContext(ctx, `DELETE FROM users WHERE id=$1`, uid); err != nil {
t.Logf("清理测试账号失败: %v", err)
}
for _, id := range ids {
os.Remove(filepath.Join(cfg.DataDir, "baselines", fmt.Sprintf("%d.sqlite", id)))
}
}
// purgeE2EUsers 清掉上次跑到一半留下的测试账号(它的 username 前缀是本测试专用的)。
func purgeE2EUsers(t *testing.T, ctx context.Context, pg *sql.DB, cfg *config.Config) {
t.Helper()
var ids []int64
rows, err := pg.QueryContext(ctx, `SELECT id FROM users WHERE username LIKE 'e2e-movement-%'`)
if err != nil {
return
}
for rows.Next() {
var id int64
rows.Scan(&id)
ids = append(ids, id)
}
rows.Close()
for _, id := range ids {
t.Logf("清掉上次遗留的测试账号 #%d", id)
dropE2EUser(t, ctx, pg, cfg, id)
}
}
// chdirModuleRoot 把工作目录切到仓库根(含 go.mod 的那一层)。
// 调试模式下模板是从磁盘按「internal/web/templates/...」读的,配置里的
// storage 路径也是相对仓库根,只有站在仓库根上才和真跑起来时一致。
func chdirModuleRoot(t *testing.T) {
t.Helper()
dir, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
for {
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
return
}
parent := filepath.Dir(dir)
if parent == dir {
t.Fatal("找不到 go.mod,无法定位仓库根目录")
}
dir = parent
}
}
// post 直接调用后台处理函数,返回重定向地址(带 ok=/err= 提示)。
// 绕过 requireAdmin 是刻意的:这里要测的是处理逻辑本身,登录态由 ctxWithUser 直接注入。
func post(t *testing.T, h func(http.ResponseWriter, *http.Request), ctx context.Context, path string, form url.Values) string {
t.Helper()
r := httptest.NewRequest("POST", path, strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r = r.WithContext(ctx)
w := httptest.NewRecorder()
h(w, r)
// 处理函数出错时会 500;正常路径是 303 重定向 + ?err= 提示
if w.Code >= 500 {
t.Fatalf("%s 处理失败: code=%d", path, w.Code)
}
loc := w.Header().Get("Location")
if strings.Contains(loc, "err=") {
t.Fatalf("%s 被拒绝: %s", path, loc)
}
return loc
}
func ctxWithUser(u *auth.User) context.Context {
return context.WithValue(context.Background(), userKey{}, u)
}
func lastMovement(t *testing.T, ctx context.Context, pg *sql.DB, datasetID int64) (int, int) {
t.Helper()
var pk int
var tr sql.NullInt64
if err := pg.QueryRowContext(ctx, `SELECT src_pk, transport_src FROM movements
WHERE dataset_id=$1 ORDER BY id DESC LIMIT 1`, datasetID).Scan(&pk, &tr); err != nil {
t.Fatalf("读取刚写入的行程失败: %v", err)
}
return pk, int(tr.Int64)
}
func copyTo(dst, src string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
out.Close()
return err
}
return out.Close()
}
func absFloat(v float64) float64 {
if v < 0 {
return -v
}
return v
}
func appleSec(t time.Time) float64 {
return float64(t.Unix()-978307200) + float64(t.Nanosecond())/1e9
}