症状:生产导出的包导不回 rond,提示「错误,备份恢复失败,你的当前数据未被更改」。 根因链:站内「地点补全」新建活动时写的是 color=''、icon=''(fillup.go 的 INSERT),而导出侧 ns() 把空串也当空(返回 nil)——图标那侧早就有 📍 兜底(注释还写着「站点自己渲染时缺省用 📍」), 颜色这侧漏了,于是写出 ZACTIVITY.ZCOLOR_ = NULL。真机这一列在任何包里都是 0 NULL/0 空串 (扫了 data/ 与 storage/ 下全部 15 个包,只有出事的那份有 1 个空),Core Data 拿到非可选属性上的 NULL 会判成损坏数据 → 整包拒收。 修复:加 activityPlaceholderColor = "gray"(与 transportPlaceholderColor 同一套写法), 活动颜色为空时兜底。 用例:TestExportNeverNullsColumnsTheDeviceAlwaysFills 补上「没选颜色的活动」场景——它守的通用 不变量是「基线里 0 个 NULL 的列,导出新行也不许 NULL」,但此前只造过交通方式,没覆盖活动。 变异验证:去掉兜底 → 两条断言都红(提示语正好复刻生产现象)。 另新增 tools/audit_export_nulls.py:拿参照包做逐列空值画像 + 全部关系列悬空 + Z_MAX 检查, 是排查「导出被拒收」最快的一步(这次就是它一击命中的)。注意判据必须是「参照里 0 个 NULL」—— ZLOCATION.ZISOCOUNTRYCODE 真机自己就漏过 1 个 NULL,说明它容忍 NULL,不要当违规去修。 全量验收 293 PASS / 0 FAIL / 0 SKIP。
203 lines
8.2 KiB
Go
203 lines
8.2 KiB
Go
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("新建交通方式没有分配编号")
|
||
}
|
||
|
||
// 活动同理:站点「地点补全」新建活动时 color/icon 写的是空串(见 fillup.go 的 INSERT),
|
||
// 而导出侧的 ns() 把空串也当空——图标那侧早就有 📍 兜底,颜色这侧曾经漏了。
|
||
var newAct int
|
||
if err := pg.QueryRowContext(ctx, `INSERT INTO activities (dataset_id, src_pk, name, color, icon)
|
||
VALUES ($1, 950001, '测试无色活动', '', '') RETURNING src_pk`, dsID).Scan(&newAct); err != nil {
|
||
t.Fatalf("插入无色活动失败: %v", err)
|
||
}
|
||
t.Cleanup(func() {
|
||
pg.ExecContext(ctx, `DELETE FROM activities WHERE dataset_id=$1 AND src_pk=950001`, dsID)
|
||
})
|
||
|
||
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) 新建的那条「没选颜色」的活动也必须自带可用的颜色与图标
|
||
var actName, actColor, actIcon sql.NullString
|
||
if err := edb.QueryRow(`SELECT ZNAME_, ZCOLOR_, ZICON_ FROM ZACTIVITY WHERE Z_PK=?`, newAct).
|
||
Scan(&actName, &actColor, &actIcon); err != nil {
|
||
t.Fatalf("导出包里找不到新建的活动 #%d: %v", newAct, err)
|
||
}
|
||
for _, c := range []struct {
|
||
col string
|
||
v sql.NullString
|
||
}{{"ZNAME_", actName}, {"ZCOLOR_", actColor}, {"ZICON_", actIcon}} {
|
||
if !c.v.Valid || c.v.String == "" {
|
||
t.Errorf("新建活动的 %s 是空的(%q):真机这列从不留空,"+
|
||
"写 NULL 会被当成损坏的数据而拒收整包", c.col, c.v.String)
|
||
}
|
||
}
|
||
|
||
// 3) 通用判据:基线里一个空值都没有的列,导出里的新行也不许出现空值
|
||
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"},
|
||
}
|