Files
rond-with-you/internal/web/export_transport_test.go
T
tamakyi 9c1413bb39 导出/合并:交通方式编号空间串了,导出会吐出两条同名方式(真机整包拒收)
继上一轮修「活动颜色为 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。
2026-09-29 13:25:50 +08:00

100 lines
3.9 KiB
Go
Raw 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 (
"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)
}
}