Files
rond-with-you/internal/web/export_placeholder_test.go
T
tamakyi 78865fc736 导出:删掉到访后,别把它指向的原始采样点留成悬空引用
真机报「备份恢复失败,你的当前数据未被更改」——这句说明它是先校验、整体回滚。
往下查的第一个醒悟:**Core Data 的 SQLite 表里根本没有外键约束**(关系由 Core Data
自己维护),所以之前跑的 PRAGMA foreign_key_check 报 0 违例毫无意义,它压根没得可查。

按关系列手查悬空引用,结果:

  原版:悬空 0
  导出:ZRAWVISIT.ZVISIT 有 2 条指向不存在的 ZVISIT —— (2051→到访46, 465→到访200)

正是用户在站点上删掉的那两条到访。ZRAWVISIT(原始 GPS 采样点)是不托管实体、
导出时原样保留,于是删到访就留下了指向已删记录的悬空关系;而真机删到访时
采样点是跟着级联删的,所以这是它自己绝不会产生的形状。

修法:visit.finish() 之后,删掉 ZVISIT 指向不存在到访的 ZRAWVISIT 行。
选「删」而不是「把 ZVISIT 置空」:真机的「未归并采样点」虽有 1461/2246 条(置空合法),
但留成未归并会让 app 重新推出一条到访,把用户删掉的记录复活。

顺带加固:
- verify_export_structure.py 新增 1.45 节「关系完整性」,手查 8 组关系列的悬空引用,
  有悬空就把结论判成「有差异」——这条 foreign_key_check 查不出来的坑进了标准验收清单。
- TestExportDropsOrphanedRawVisits:删一条带采样点的到访 → 导出 → 断言没有悬空;已做变异验证。
2026-09-20 18:05:47 +08:00

176 lines
7.0 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 (
"database/sql"
"fmt"
"os"
"path/filepath"
"testing"
"rond-with-you/internal/rond"
)
// TestExportNeverNullsColumnsTheDeviceAlwaysFills 守一条**通用**的导出不变量:
//
// 基线里 100% 有值的列,站点新建的行也不许留空。
//
// 真机对某些列从来不写 NULL/空串(比如 ZTRANSPORT 的名称/颜色/图标),那些列在它的模型里
// 很可能就是非可选属性;我们给新行留 NULL,等于塞回去一个它自己从不产生的形状。
//
// 2026-09-20 就是这么栽的:站点新建了交通方式「步行」但没选颜色/图标,而站点不单独维护
// 交通方式表(颜色/图标是从位移上聚合的),导出就把 ZCOLOR_/ZICON_ 写成了 NULL——
// 逐列比对时它是唯一一处「老行全有值、新行却是空」的列。
func TestExportNeverNullsColumnsTheDeviceAlwaysFills(t *testing.T) {
f := newMovementFixture(t)
ctx, pg, dsID := f.ctx, f.pg, f.dsID
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)
}
// 故意「只填名字」建成新方式:颜色/图标留空,复现用户那次的操作
post(t, f.srv.editMovementCreate, ctx, "/admin/edit/movement", map[string][]string{
"from_visit": {fmt.Sprint(fromVisit)},
"to_visit": {fmt.Sprint(toVisit)},
"transport_src": {transportNewValue},
"transport_name": {"测试步行方式"},
})
_, newTrans := lastMovement(t, ctx, pg, dsID)
if newTrans <= 0 {
t.Fatal("新建交通方式没有分配编号")
}
out := filepath.Join(t.TempDir(), "export.rondbackup")
if err := os.WriteFile(out, f.export(t), 0o644); err != nil {
t.Fatal(err)
}
edb, err := rond.Open(out, t.TempDir())
if err != nil {
t.Fatalf("打开导出包失败: %v", err)
}
t.Cleanup(func() { edb.Close() })
srcDB := f.openSource(t)
// 1) 新建的那条方式必须自带可用的颜色与图标
var name, color, icon sql.NullString
if err := edb.QueryRow(`SELECT ZNAME_, ZCOLOR_, ZICON_ FROM ZTRANSPORT WHERE Z_PK=?`, newTrans).
Scan(&name, &color, &icon); err != nil {
t.Fatalf("导出包里找不到新建的交通方式 #%d: %v", newTrans, err)
}
for _, c := range []struct {
col string
v sql.NullString
}{{"ZNAME_", name}, {"ZCOLOR_", color}, {"ZICON_", icon}} {
if !c.v.Valid || c.v.String == "" {
t.Errorf("新建交通方式的 %s 是空的(%q):真机这列从不留空", c.col, c.v.String)
}
}
// 2) 通用判据:基线里一个空值都没有的列,导出里的新行也不许出现空值
for _, tbl := range []string{"ZVISIT", "ZLOCATION", "ZACTIVITY", "ZMOVEMENT", "ZTRANSPORT", "ZTAG"} {
var baseMax sql.NullInt64
if err := srcDB.QueryRow(`SELECT MAX(Z_PK) FROM ` + tbl).Scan(&baseMax); err != nil || !baseMax.Valid {
continue
}
for _, col := range columnNames(t, srcDB, tbl) {
if countNull(t, srcDB, fmt.Sprintf(`SELECT COUNT(*) FROM %s WHERE %s IS NULL`, tbl, col)) > 0 {
continue // 基线本来就有空值,这一列不算「真机从来不空」
}
q := fmt.Sprintf(`SELECT COUNT(*) FROM %s WHERE Z_PK > ? AND %s IS NULL`, tbl, col)
if n := countNull(t, edb, q, baseMax.Int64); n > 0 {
t.Errorf("%s.%s:基线里一个空值都没有,导出却有 %d 个新行是 NULL —— "+
"真机从不产生这种形状,它会当成损坏的数据", tbl, col, n)
}
}
}
}
// columnNames 取一张表的列名(跳过 Core Data 内部列,它们由基线原样带回)。
func columnNames(t *testing.T, db *sql.DB, tbl string) []string {
t.Helper()
rows, err := db.Query(`SELECT name FROM pragma_table_info(?)`, tbl)
if err != nil {
t.Fatalf("读 %s 的列名失败: %v", tbl, err)
}
defer rows.Close()
var out []string
for rows.Next() {
var n string
if err := rows.Scan(&n); err != nil {
t.Fatal(err)
}
out = append(out, n)
}
return out
}
func countNull(t *testing.T, db *sql.DB, q string, args ...any) int {
t.Helper()
var n int
if err := db.QueryRow(q, args...).Scan(&n); err != nil {
t.Fatalf("查询失败 %s: %v", q, err)
}
return n
}
// TestExportDropsOrphanedRawVisits 守「导出包的关系图不许有悬空引用」。
//
// ZRAWVISIT(原始 GPS 采样点)是我们**不托管**的实体,导出时原样保留;而站点删掉一条到访时,
// 指向它的采样点就留成了悬空 —— Core Data 的关系由它自己维护、SQLite 里没有外键约束,
// 所以 `foreign_key_check` 查不出来,但它是真机自己绝不会产生的形状(真机删到访时采样点级联删)。
// 留着还有副作用:app 会把「没归并的采样点」重新推成一条到访,删掉的记录又回来了。
func TestExportDropsOrphanedRawVisits(t *testing.T) {
f := newMovementFixture(t)
srcDB := f.openSource(t)
// 挑一条「有原始采样点」的到访:原始采样点的 ZVISIT 就是到访的 Z_PK(= 站点的 src_pk)
var visitPK int
if err := srcDB.QueryRow(`SELECT ZVISIT FROM ZRAWVISIT
WHERE ZVISIT IS NOT NULL GROUP BY ZVISIT ORDER BY ZVISIT LIMIT 1`).Scan(&visitPK); err != nil {
t.Skipf("基线里没有带采样点的到访,跳过: %v", err)
}
post(t, f.srv.editVisitDelete, f.ctx, "/admin/edit/visit/delete", map[string][]string{
"src_pk": {fmt.Sprint(visitPK)},
})
out := filepath.Join(t.TempDir(), "export.rondbackup")
if err := os.WriteFile(out, f.export(t), 0o644); err != nil {
t.Fatal(err)
}
edb, err := rond.Open(out, t.TempDir())
if err != nil {
t.Fatalf("打开导出包失败: %v", err)
}
t.Cleanup(func() { edb.Close() })
if n := countNull(t, edb, `SELECT COUNT(*) FROM ZRAWVISIT WHERE ZVISIT=?`, visitPK); n != 0 {
t.Errorf("删掉到访 #%d 后,它的 %d 条原始采样点还留着(悬空引用)", visitPK, n)
}
// 通用判据:整包不许有任何悬空引用(Core Data 自己维护关系,pragma 查不到)
for _, ref := range unmanagedRefs {
q := fmt.Sprintf(`SELECT COUNT(*) FROM %s x WHERE x.%s IS NOT NULL AND x.%s<>0
AND NOT EXISTS (SELECT 1 FROM %s y WHERE y.Z_PK = x.%s)`,
ref[0], ref[1], ref[1], ref[2], ref[1])
if n := countNull(t, edb, q); n > 0 {
t.Errorf("%s.%s 有 %d 条指向不存在的 %s —— 真机从不产生悬空关系", ref[0], ref[1], n, ref[2])
}
}
}
// unmanagedRefs 是「不托管的表指向托管实体」的关系列(库、列、目标表)。
// 加新表时照这个格式补,通用判据会自动覆盖。
var unmanagedRefs = [][3]string{
{"ZRAWVISIT", "ZVISIT", "ZVISIT"},
{"ZRAWVISIT", "ZLOCATION", "ZLOCATION"},
{"ZRAWVISIT", "ZACTIVITY", "ZACTIVITY"},
{"ZSTAYSEGMENT", "ZLOCATION", "ZLOCATION"},
{"ZUSERPHOTO", "ZVISIT_", "ZVISIT"},
{"ZVISITPHOTOFAVORITE", "ZVISIT_", "ZVISIT"},
{"ZKEYWORD", "ZACTIVITY_", "ZACTIVITY"},
{"ZPOICATEGORY", "ZACTIVITY_", "ZACTIVITY"},
}