导出/合并:交通方式编号空间串了,导出会吐出两条同名方式(真机整包拒收)
继上一轮修「活动颜色为 NULL」之后,生产重新导出 `data/20260929.rondbackup` 依旧导不回 rond。 这次拿**最后一次被真机接受的包**(20260920-fixed-A-数据修复)当参照逐项比对:SQLite 头、31 表 / 47 索引 / 建表语句 / 列定义、`Z_METADATA.Z_PLIST` 与 `Z_MODELCACHE`(逐字节)、值类型分布、文本合法 字节、BLOB 长度、UUID 唯一性、关系完整性、`Z_OPT`、行级时间不变量——全部一致;唯一差异是 **`ZTRANSPORT` 里出现了两条同名「公交」(Z_PK 2 与 4),而真机与可导入的 A 包都只有一条**。 根因:`mergeVisits` 把实机的 `ZTRANSPORT` 编号原样写进 `movements.transport_src`。站点没有交通方式表, 方式只活在位移的 transport_* 四列里,两套编号空间同一个数字含义不同(实测 src=4:站点=电🐔、实机=公交, src 3/5/6 同样撞车)。于是同一个 src 带上两种含义:导出按 src 聚合只能取一个名字 → 另一种含义的位移挂错 方式;更糟的是吐出两条同名方式,真机那边是模型唯一约束级别的违规 → 整包拒收。 修复两处: 1. 合并侧 `mergeVisits`:按名称把实机方式归一到站点编号(新增 `(*Server).transportsByName`:同名沿用 站点已有的编号,没有才 `nextSrcPK(transportSeq)` 新发一个)。 2. 导出侧 `queryTransports`:改为按**名称**归组(名称就是身份),用量最多的名称优先认领编号,其余编号的 位移改挂到它上面;并清掉「基线里同名但编号已被归并掉」的旧行——`keepMissing` 会把它原样带回,于是又是 两条同名。这段清理必须排在所有托管表写完之后,否则兜底判据看到的还是基线里未重写的 ZMOVEMENT。 真实数据效果(本地库,与生产同一形状):方式恢复成站点原本那套 1火车/2公交/3高铁/4电🐔/5地铁/6步行、 无重名;位移各挂回自己的方式(电🐔 336、地铁 25、高铁 23、公交 19、火车 9、步行 3——修复前只有 6 条能对上)。 用例:`TestExportMergesDuplicateTransportNames`(同名归并 + 位移挂回自己那条 + 无悬空引用)、 `TestMergeKeepsTransportNumberingConsistent`(合并后同一 transport_src 不许有两种名称,附「位移确实被补 回来了」的哨兵断言避免写成空用例)。三处都做了变异验证:去掉改挂 / 去掉旧行清理 / 合并照抄实机编号 → 对应用例分别变红。 全量验收 295 PASS / 0 FAIL / 0 SKIP。
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"rond-with-you/internal/rond"
|
||||
)
|
||||
|
||||
// 交通方式是**全局表、以名称区分**:同名两条会被真机当成损坏数据,整包拒收
|
||||
// (2026-09-29 生产导出就是这么被拒的:`ZTRANSPORT` 里两条「公交」)。
|
||||
//
|
||||
// 站点的 transport_src 是另一套编号空间——把实机备份合并进来时,同一个 src 会带上两种含义
|
||||
// (实测 src=4 既是「电🐔」又是「公交」)。导出必须按**名称**归并,并且把每条位移挂回
|
||||
// 它自己那个名称对应的方式上;只按 src 取一个名字会让另一种含义的位移挂错方式。
|
||||
func TestExportMergesDuplicateTransportNames(t *testing.T) {
|
||||
f := newMovementFixture(t)
|
||||
ctx, pg, dsID := f.ctx, f.pg, f.dsID
|
||||
|
||||
// 造出「同一个 src、两种名称」的形状:正是合并实机备份留下的样子
|
||||
var k1, k2 int
|
||||
if err := pg.QueryRowContext(ctx, `SELECT
|
||||
(SELECT src_pk FROM movements WHERE dataset_id=$1 AND transport_src IS NOT NULL ORDER BY id LIMIT 1),
|
||||
(SELECT src_pk FROM movements WHERE dataset_id=$1 AND transport_src IS NOT NULL ORDER BY id OFFSET 1 LIMIT 1)`,
|
||||
dsID).Scan(&k1, &k2); err != nil {
|
||||
t.Fatalf("挑位移失败: %v", err)
|
||||
}
|
||||
for _, v := range []struct {
|
||||
pk int
|
||||
name string
|
||||
color string
|
||||
icon string
|
||||
}{{k1, "电🐔", "purple", "moped.fill"}, {k2, "公交", "red", "bus.fill"}} {
|
||||
if _, err := pg.ExecContext(ctx, `UPDATE movements SET transport_src=900, transport_name=$3,
|
||||
transport_color=$4, transport_icon=$5 WHERE dataset_id=$1 AND src_pk=$2`,
|
||||
dsID, v.pk, v.name, v.color, v.icon); err != nil {
|
||||
t.Fatalf("预置失败: %v", err)
|
||||
}
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
pg.ExecContext(ctx, `DELETE FROM movements WHERE dataset_id=$1 AND transport_src=900`, dsID)
|
||||
})
|
||||
// 再把站点原本那条「公交」(src=2)改名,于是「公交」这个名字只剩注入的那条:
|
||||
// 归并后基线里 src=2 那行就成了「同名但编号已被归并掉」的旧行,必须被删掉,
|
||||
// 否则 keepMissing 会把它原样留下 → 包里又是两条同名方式
|
||||
if _, err := pg.ExecContext(ctx, `UPDATE movements SET transport_name='巴士'
|
||||
WHERE dataset_id=$1 AND transport_src=2`, dsID); err != nil {
|
||||
t.Fatalf("预置改名失败: %v", err)
|
||||
}
|
||||
|
||||
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() })
|
||||
|
||||
// 1) 同名不许出现两条
|
||||
rows, err := edb.Query(`SELECT ZNAME_ FROM ZTRANSPORT WHERE ZNAME_ IS NOT NULL
|
||||
GROUP BY ZNAME_ HAVING count(*) > 1`)
|
||||
if err != nil {
|
||||
t.Fatalf("读 ZTRANSPORT 失败: %v", err)
|
||||
}
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Errorf("导出包里有两条同名交通方式 %q:真机会判成损坏数据、整包拒收", name)
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 2) 每条位移必须挂回自己名称对应的那条方式
|
||||
for _, want := range []struct {
|
||||
pk int
|
||||
name string
|
||||
}{{k1, "电🐔"}, {k2, "公交"}} {
|
||||
var got string
|
||||
if err := edb.QueryRow(`SELECT t.ZNAME_ FROM ZMOVEMENT m JOIN ZTRANSPORT t ON t.Z_PK = m.ZTRANSPORT_
|
||||
WHERE m.Z_PK = ?`, want.pk).Scan(&got); err != nil {
|
||||
t.Fatalf("位移 #%d 的方式查不到: %v", want.pk, err)
|
||||
}
|
||||
if got != want.name {
|
||||
t.Errorf("位移 #%d 应挂到 %q,实际挂到了 %q(归并后要按位移自己的名称改挂)", want.pk, want.name, got)
|
||||
}
|
||||
}
|
||||
// 3) 归并后不许留下悬空的 ZTRANSPORT_ 引用
|
||||
if n := countNull(t, edb, `SELECT count(*) FROM ZMOVEMENT m WHERE m.ZTRANSPORT_ IS NOT NULL
|
||||
AND m.ZTRANSPORT_ <> 0 AND NOT EXISTS (SELECT 1 FROM ZTRANSPORT t WHERE t.Z_PK = m.ZTRANSPORT_)`); n != 0 {
|
||||
t.Errorf("归并后有 %d 条位移指向不存在的方式", n)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user