导出:新建的交通方式别把颜色/图标写成 NULL

排查「导出包导不回真机」时逐列比对发现的唯一实质缺陷:
站点新建的交通方式「步行」导出来是 ZCOLOR_=NULL / ZICON_=NULL,而真机原有 5 条
(火车/公交/高铁/电🐔/地铁)全部有值 —— 真机这两列从不写 NULL、也不写空串。

成因链:站点不单独维护交通方式表(颜色/图标是从 movements 上聚合的),
建方式时没选就存成 NULL,queryTransports 的 FILTER (WHERE ... IS NOT NULL) 聚成 NULL,
ns() 又把空串和 NULL 一起变成 nil,写进包里就是 NULL。

修法:只给**新建的行**补占位(gray / circle.fill),基线里已有的行不碰 ——
那种情况 keepNilCols 会保留真机原值,擅自补占位反而会把真值盖掉。
与「新建活动补 blue/📍」同一套做法。

同时加一条通用的守卫(export_placeholder_test.go):判据是
「基线里 100% 有值的列,导出里的新行也不许为空」,对所有托管表逐列扫。
只修 ZTRANSPORT 是治标,这条才是同类问题的根治办法。已做变异验证。
This commit is contained in:
tamakyi
2026-09-20 17:37:04 +08:00
parent 693ee82ab8
commit 56c7283e38
2 changed files with 142 additions and 1 deletions
+117
View File
@@ -0,0 +1,117 @@
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
}