导出:新建活动没选颜色时补占位色,别再写 ZCOLOR_=NULL(真机拒收整包)
症状:生产导出的包导不回 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。
This commit is contained in:
@@ -43,6 +43,17 @@ func TestExportNeverNullsColumnsTheDeviceAlwaysFills(t *testing.T) {
|
||||
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)
|
||||
@@ -69,7 +80,23 @@ func TestExportNeverNullsColumnsTheDeviceAlwaysFills(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 通用判据:基线里一个空值都没有的列,导出里的新行也不许出现空值
|
||||
// 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 {
|
||||
|
||||
Reference in New Issue
Block a user