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

425 lines
13 KiB
Go
Raw Permalink 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 rond
import (
"bytes"
"database/sql"
"os"
"path/filepath"
"reflect"
"testing"
"time"
)
// TestBaselineKeepsCoreData 守住导出的地基:基线库必须完整保留 Core Data 的
// 元数据表与索引、以及我们不落库的实体(ZRAWVISIT 等)。一旦有人重新改成
// 「纯建表重建」,这里会立刻失败——那正是当初导出包 85KB、rond 提示恢复失败的原因。
// 需要环境变量 ROND_SRC(原始 .rondbackup);未设置时跳过。
func TestBaselineKeepsCoreData(t *testing.T) {
src := os.Getenv("ROND_SRC")
if src == "" {
t.Skip("设置 ROND_SRC 后运行基线保真度检查")
}
base := filepath.Join(t.TempDir(), "baseline.sqlite")
if err := WriteBaseline(src, base); err != nil {
t.Fatalf("生成基线库失败: %v", err)
}
before := shapeOf(t, src)
after := shapeOfFile(t, base)
if after.meta == 0 || after.metaPlist == 0 {
t.Errorf("基线缺少 Z_METADATA / Z_PLIST,Core Data 会拒绝打开")
}
if after.modelCache == 0 {
t.Errorf("基线缺少 Z_MODELCACHE")
}
if !reflect.DeepEqual(before.tables, after.tables) {
t.Errorf("表集合不一致\n原: %v\n基: %v", before.tables, after.tables)
}
if !reflect.DeepEqual(before.indexes, after.indexes) {
t.Errorf("索引集合不一致:缺 %v", diffList(before.indexes, after.indexes))
}
for _, kv := range [][3]any{
{"ZRAWVISIT", before.rawVisit, after.rawVisit},
{"ZKEYWORD", before.keyword, after.keyword},
{"ZVISIT", before.visit, after.visit},
{"ZLOCATION", before.location, after.location},
} {
if kv[1] != kv[2] {
t.Errorf("%s 行数不一致: 原 %v vs 基 %v", kv[0], kv[1], kv[2])
}
}
}
// sqliteShape 是「表/索引集合 + 关键表行数」,用于对比两个库的结构。
type sqliteShape struct {
tables []string
indexes []string
meta int
metaPlist int
modelCache int
rawVisit int
keyword int
visit int
location int
}
func shapeOf(t *testing.T, archive string) sqliteShape {
t.Helper()
dir := t.TempDir()
db, err := Open(archive, dir)
if err != nil {
t.Fatalf("打开 %s 失败: %v", archive, err)
}
defer db.Close()
return readShape(t, db)
}
// shapeOfFile 与 shapeOf 相同,但直接读一个已解包的 .sqlite 文件(基线库形态)。
func shapeOfFile(t *testing.T, path string) sqliteShape {
t.Helper()
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path))
if err != nil {
t.Fatalf("打开 %s 失败: %v", path, err)
}
defer db.Close()
return readShape(t, db)
}
func readShape(t *testing.T, db *sql.DB) sqliteShape {
t.Helper()
return sqliteShape{
tables: queryNames(t, db, `SELECT name FROM sqlite_master WHERE type='table' ORDER BY name`),
indexes: queryNames(t, db, `SELECT name FROM sqlite_master WHERE type='index' ORDER BY name`),
meta: countRows(t, db, `SELECT count(*) FROM Z_METADATA`),
metaPlist: countRows(t, db, `SELECT count(*) FROM Z_METADATA WHERE length(Z_PLIST)>0`),
modelCache: countRows(t, db, `SELECT count(*) FROM Z_MODELCACHE`),
rawVisit: countRows(t, db, `SELECT count(*) FROM ZRAWVISIT`),
keyword: countRows(t, db, `SELECT count(*) FROM ZKEYWORD`),
visit: countRows(t, db, `SELECT count(*) FROM ZVISIT`),
location: countRows(t, db, `SELECT count(*) FROM ZLOCATION`),
}
}
func queryNames(t *testing.T, db *sql.DB, q string) []string {
t.Helper()
rows, err := db.Query(q)
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.Fatal(err)
}
out = append(out, s)
}
return out
}
func countRows(t *testing.T, db *sql.DB, q string) int {
t.Helper()
var n int
if err := db.QueryRow(q).Scan(&n); err != nil {
t.Fatalf("查询失败 %s: %v", q, err)
}
return n
}
func diffList(have, want []string) []string {
set := map[string]bool{}
for _, v := range want {
set[v] = true
}
var out []string
for _, v := range have {
if !set[v] {
out = append(out, v)
}
}
return out
}
// TestExportRoundtrip 用 rond 自己的 Open+Parse 反向解析「导出包」,与原始备份逐项比对,
// 验证从 PostgreSQL 重建的 .rondbackup 与 rond 原始格式一致。
// 需要环境变量 ROND_SRC(原始 .rondbackup)与 ROND_EXPORT(导出包);未设置时跳过。
func TestExportRoundtrip(t *testing.T) {
src := os.Getenv("ROND_SRC")
exp := os.Getenv("ROND_EXPORT")
if src == "" || exp == "" {
t.Skip("设置 ROND_SRC 与 ROND_EXPORT 后运行往返对比")
}
a := parseForTest(t, src)
c := parseForTest(t, exp)
na := countArrival(a)
nc := countArrival(c)
t.Logf("原始: visits(有到达)=%d places=%d tags=%d acts=%d moves=%d weather=%d rawvisits=%d",
na, len(a.Locations), len(a.Tags), len(a.Activities), len(a.Movements), len(a.Weather), a.RawVisitNum)
t.Logf("导出: visits(有到达)=%d places=%d tags=%d acts=%d moves=%d weather=%d rawvisits=%d",
nc, len(c.Locations), len(c.Tags), len(c.Activities), len(c.Movements), len(c.Weather), c.RawVisitNum)
if na != nc {
t.Errorf("到访数不一致: 原 %d vs 导 %d", na, nc)
}
if len(a.Locations) != len(c.Locations) {
t.Errorf("地点数不一致: 原 %d vs 导 %d", len(a.Locations), len(c.Locations))
}
if len(a.Activities) != len(c.Activities) {
t.Errorf("活动类型数不一致: 原 %d vs 导 %d", len(a.Activities), len(c.Activities))
}
if len(a.Tags) != len(c.Tags) {
t.Errorf("标签数不一致: 原 %d vs 导 %d", len(a.Tags), len(c.Tags))
}
if len(a.Movements) != len(c.Movements) {
t.Errorf("位移数不一致: 原 %d vs 导 %d", len(a.Movements), len(c.Movements))
}
if len(a.Weather) != len(c.Weather) {
t.Errorf("天气数不一致: 原 %d vs 导 %d", len(a.Weather), len(c.Weather))
}
// 逐条抽查时间与坐标是否一致(按 SrcPK 对齐)
loc := map[int]Location{}
for _, l := range a.Locations {
loc[l.SrcPK] = l
}
maxDiff := 0.0
for _, l := range c.Locations {
o, ok := loc[l.SrcPK]
if !ok {
t.Errorf("导出多出地点 %d", l.SrcPK)
continue
}
if d := absf(o.Lat - l.Lat); d > maxDiff {
maxDiff = d
}
if d := absf(o.Lon - l.Lon); d > maxDiff {
maxDiff = d
}
}
t.Logf("坐标最大偏差: %.9f", maxDiff)
if maxDiff > 1e-9 {
t.Errorf("坐标出现偏差: %v", maxDiff)
}
vm := map[int]Visit{}
for _, v := range a.Visits {
vm[v.SrcPK] = v
}
var maxTimeDelta time.Duration
for _, v := range c.Visits {
o, ok := vm[v.SrcPK]
if !ok {
t.Errorf("导出多出到访 %d", v.SrcPK)
continue
}
if (o.Arrival == nil) != (v.Arrival == nil) {
t.Errorf("到访 %d 到达时间有无不一致", v.SrcPK)
continue
}
if o.Arrival == nil {
continue
}
// Core Data 用 float64 存秒数,往返存在亚微秒级舍入,毫秒容差内视为一致
d := o.Arrival.Sub(*v.Arrival)
if d < 0 {
d = -d
}
if d > maxTimeDelta {
maxTimeDelta = d
}
if d > time.Millisecond {
t.Errorf("到访 %d 到达时间偏差过大: 原 %v vs 导 %v", v.SrcPK, o.Arrival, *v.Arrival)
}
}
t.Logf("时间最大偏差: %v", maxTimeDelta)
}
func parseForTest(t *testing.T, path string) *Backup {
t.Helper()
dir := t.TempDir()
db, err := Open(path, dir)
if err != nil {
t.Fatalf("打开 %s 失败: %v", path, err)
}
defer db.Close()
b, err := Parse(db)
if err != nil {
t.Fatalf("解析 %s 失败: %v", path, err)
}
return b
}
func countArrival(b *Backup) int {
n := 0
for _, v := range b.Visits {
if v.Arrival != nil {
n++
}
}
return n
}
// TestLastArrivalDateMatchesVisits 守住地点详情的「最近活跃时间」。
// rond 直接显示 ZLOCATION.ZLASTARRIVALDATE_,它是该地点最晚一次到访到达时间的缓存:
// 有到访却留空,rond 里就永远显示「无数据」;时间对不上,显示的就是过期值。
// 需要环境变量 ROND_EXPORT(导出后的 .rondbackup);未设置时跳过。
func TestLastArrivalDateMatchesVisits(t *testing.T) {
exp := os.Getenv("ROND_EXPORT")
if exp == "" {
t.Skip("设置 ROND_EXPORT 后运行「最近活跃时间」一致性检查")
}
db, err := Open(exp, t.TempDir())
if err != nil {
t.Fatalf("打开 %s 失败: %v", exp, err)
}
defer db.Close()
rows, err := db.Query(`SELECT l.Z_PK, COALESCE(l.ZNAME_,''), l.ZLASTARRIVALDATE_, v.mx
FROM ZLOCATION l
LEFT JOIN (SELECT ZLOCATION AS loc, max(ZARRIVALDATE_) mx FROM ZVISIT GROUP BY ZLOCATION) v
ON v.loc = l.Z_PK`)
if err != nil {
t.Fatalf("查询地点: %v", err)
}
defer rows.Close()
checked, unvisited := 0, 0
for rows.Next() {
var pk int
var name string
var last, mx sql.NullFloat64
if err := rows.Scan(&pk, &name, &last, &mx); err != nil {
t.Fatalf("读取地点: %v", err)
}
switch {
case !mx.Valid:
// 从未到访:rond 自己写的是 distantPast 哨兵或干脆不写,两种都行
unvisited++
case !last.Valid:
t.Errorf("地点 %d(%s) 有到访记录,但 ZLASTARRIVALDATE_ 为空——rond 会显示「无数据」", pk, name)
default:
checked++
// Core Data 用 float64 存秒数,毫秒容差内视为一致
if d := absf(last.Float64 - mx.Float64); d > 1e-3 {
t.Errorf("地点 %d(%s) 最近活跃时间与最新到访不符: 差 %.3f 秒", pk, name, d)
}
}
}
if err := rows.Err(); err != nil {
t.Fatalf("遍历地点: %v", err)
}
t.Logf("已核对 %d 个有到访的地点(另有 %d 个从未到访)", checked, unvisited)
if checked == 0 {
t.Error("没有核对到任何有到访的地点,样本可能不对")
}
}
// TestUUIDv5 守住补 UUID 的确定性:同一条记录每次导出必须拿到同一个 UUID。
// 若退化成随机,每次导出的包都会让 rond 把同一批记录当成新的。
func TestUUIDv5(t *testing.T) {
a, b := uuidV5("ZVISIT/42"), uuidV5("ZVISIT/42")
if !bytes.Equal(a, b) {
t.Errorf("同名两次生成不一致: %x vs %x", a, b)
}
if len(a) != 16 {
t.Fatalf("长度应为 16 字节,实际 %d", len(a))
}
if a[6]>>4 != 5 {
t.Errorf("版本位应为 5,实际 %d", a[6]>>4)
}
if a[8]>>6 != 2 {
t.Errorf("变体位应为 RFC 4122(10xx),实际 %d", a[8]>>6)
}
if bytes.Equal(a, uuidV5("ZVISIT/43")) {
t.Error("不同记录不应拿到同一个 UUID")
}
if bytes.Equal(uuidV5("ZVISIT/42"), uuidV5("ZACTIVITY/42")) {
t.Error("不同表不应拿到同一个 UUID")
}
}
// TestProgramRowsFillNativeColumns 守住「站点新建的行也要带上真机必有的列」。
// ZLOCATION 的原始 GPS 与 ZVISIT/ZACTIVITY 的 UUID 在真机包里几乎 100% 有值
// (实测 323/323、822/824、30/30),而站点这边没有对应字段,导出必须替新行补上,
// 否则 rond 里就会缺东西——「最近活跃时间」显示「无数据」就是同一个坑。
// 需要环境变量 ROND_SRC(原始包)与 ROND_EXPORT(导出包);未设置时跳过。
func TestProgramRowsFillNativeColumns(t *testing.T) {
src, exp := os.Getenv("ROND_SRC"), os.Getenv("ROND_EXPORT")
if src == "" || exp == "" {
t.Skip("设置 ROND_SRC 与 ROND_EXPORT 后运行原生列补全检查")
}
old, cur := openBackup(t, src), openBackup(t, exp)
defer old.Close()
defer cur.Close()
for _, c := range []struct{ table, col string }{
{"ZLOCATION", "ZRAWLATITUDE"},
{"ZLOCATION", "ZRAWLONGITUDE"},
{"ZVISIT", "ZIDENTIFIER_"},
{"ZACTIVITY", "ZUID_"},
{"ZMOVEMENT", "ZTYPE_"},
} {
var base int
if err := old.QueryRow(`SELECT COALESCE(max(Z_PK),0) FROM ` + c.table).Scan(&base); err != nil {
t.Fatalf("读取 %s 基线最大主键: %v", c.table, err)
}
var total, missing int
if err := cur.QueryRow(`SELECT count(*), COALESCE(sum(CASE WHEN `+c.col+` IS NULL THEN 1 ELSE 0 END),0)
FROM `+c.table+` WHERE Z_PK > ?`, base).Scan(&total, &missing); err != nil {
t.Fatalf("统计 %s.%s: %v", c.table, c.col, err)
}
if total == 0 {
continue // 这一版没有新增行
}
if missing > 0 {
t.Errorf("%s.%s:%d 个站点新建的行里有 %d 个为空(真机几乎全部有值)", c.table, c.col, total, missing)
}
t.Logf("%s.%s:新建 %d 行,全部有值", c.table, c.col, total)
}
}
// TestMovementTypeNeverNull 守住 ZMOVEMENT.ZTYPE_ 全表非空。
// 真机 791/791 都有值(2=无交通方式/步行、5=有交通方式,另有少量 0/4),
// 说明这个属性在 Core Data 里是非可选的——留 NULL 轻则显示异常,重则整包恢复失败。
// 需要环境变量 ROND_EXPORT(导出包);未设置时跳过。
func TestMovementTypeNeverNull(t *testing.T) {
exp := os.Getenv("ROND_EXPORT")
if exp == "" {
t.Skip("设置 ROND_EXPORT 后运行位移类型非空检查")
}
db := openBackup(t, exp)
defer db.Close()
var total, missing int
if err := db.QueryRow(`SELECT count(*),
COALESCE(sum(CASE WHEN ZTYPE_ IS NULL THEN 1 ELSE 0 END),0) FROM ZMOVEMENT`).
Scan(&total, &missing); err != nil {
t.Fatalf("统计 ZMOVEMENT.ZTYPE_: %v", err)
}
if total == 0 {
t.Skip("导出包里没有位移记录")
}
if missing > 0 {
t.Errorf("ZMOVEMENT 共 %d 条,其中 %d 条 ZTYPE_ 为空", total, missing)
}
t.Logf("ZMOVEMENT 共 %d 条,ZTYPE_ 全部有值", total)
}
func openBackup(t *testing.T, path string) *sql.DB {
t.Helper()
db, err := Open(path, t.TempDir())
if err != nil {
t.Fatalf("打开 %s 失败: %v", path, err)
}
return db
}
func absf(v float64) float64 {
if v < 0 {
return -v
}
return v
}