From 56c7283e38e12763864ebea3634e0e25701e55e6 Mon Sep 17 00:00:00 2001 From: tamakyi Date: Sun, 20 Sep 2026 17:37:04 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AF=BC=E5=87=BA=EF=BC=9A=E6=96=B0=E5=BB=BA?= =?UTF-8?q?=E7=9A=84=E4=BA=A4=E9=80=9A=E6=96=B9=E5=BC=8F=E5=88=AB=E6=8A=8A?= =?UTF-8?q?=E9=A2=9C=E8=89=B2/=E5=9B=BE=E6=A0=87=E5=86=99=E6=88=90=20NULL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 排查「导出包导不回真机」时逐列比对发现的唯一实质缺陷: 站点新建的交通方式「步行」导出来是 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 是治标,这条才是同类问题的根治办法。已做变异验证。 --- internal/rond/export.go | 26 +++++- internal/web/export_placeholder_test.go | 117 ++++++++++++++++++++++++ 2 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 internal/web/export_placeholder_test.go diff --git a/internal/rond/export.go b/internal/rond/export.go index 5971148..94bcff1 100644 --- a/internal/rond/export.go +++ b/internal/rond/export.go @@ -86,6 +86,13 @@ var keepNilCols = map[string][]string{ "ZTRANSPORT": {"ZNAME_", "ZCOLOR_", "ZICON_"}, } +// 新建交通方式没选颜色/图标时的占位值。真机这两列从来不空,写 NULL 是它没见过的形状; +// 颜色取 rond 系统色名,图标取一个必定存在的 SF Symbol。 +const ( + transportPlaceholderColor = "gray" + transportPlaceholderIcon = "circle.fill" +) + // movementTypeDefault 给站点新建的位移补 ZTYPE_:真机里这条规律覆盖 757/791 行 // (有交通方式→5、没有→2),剩下的是用户在 app 里手改过的不规则取值。 func movementTypeDefault(transportSet bool) int { @@ -400,7 +407,24 @@ func patchSQLite(ctx context.Context, db *sql.DB, datasetID int64, sqlitePath st // 没有任何行程引用的方式保持基线原样,不当成「已删除」 tr.keepMissing = true for _, t := range transports { - if err := tr.write(t.SrcPK, []any{ns(t.Name), ns(t.Color), ns(t.Icon)}, raws["ZTRANSPORT"][t.SrcPK]); err != nil { + name, color, icon := ns(t.Name), ns(t.Color), ns(t.Icon) + // 真机这三列从来没出现过空值(NULL 和空串都没有),而站点不单独维护交通方式表、 + // 颜色/图标是从位移上聚合的,用户建方式时没选就是空。**新建的行**必须补占位, + // 否则会把 NULL 写进包里——rond 拿到的是它自己从来不会产生的形状 + // (2026-09-20:新建的「步行」就是这样导出的,与新建活动补 blue/📍 同一套做法)。 + // 基线里已有的行不碰:那种情况交给 keepNilCols 保留真机原值。 + if !known["ZTRANSPORT"][t.SrcPK] { + if name == nil { + name = "未命名" + } + if color == nil { + color = transportPlaceholderColor + } + if icon == nil { + icon = transportPlaceholderIcon + } + } + if err := tr.write(t.SrcPK, []any{name, color, icon}, raws["ZTRANSPORT"][t.SrcPK]); err != nil { return fmt.Errorf("写入交通方式失败: %w", err) } } diff --git a/internal/web/export_placeholder_test.go b/internal/web/export_placeholder_test.go new file mode 100644 index 0000000..de382f7 --- /dev/null +++ b/internal/web/export_placeholder_test.go @@ -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 +}