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 }