Files
rond-with-you/internal/web/export_placeholder_test.go
tamakyi f26537b1e9 导出:新建活动没选颜色时补占位色,别再写 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。
2026-09-29 11:50:06 +08:00

203 lines
8.2 KiB
Go
Raw Permalink 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("新建交通方式没有分配编号")
}
// 活动同理:站点「地点补全」新建活动时 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"},
}