first release
This commit is contained in:
@@ -0,0 +1,424 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user