package main import ( "encoding/json" "fmt" "math/big" "os" "strconv" "strings" "testing" "git.ycl.pub/yl32940/TsDB2/DB/core" "git.ycl.pub/yl32940/TsDB2/DB/core/page" "git.ycl.pub/yl32940/TsDB2/DB/core/types" ) // ============================ 列型表同步守卫 ============================ // // analyzer 在自己的模块里手写偏移直接读盘 (见 layout_assert.go 的说明)。布局偏移有 // 编译期断言兜着, 但"列类型 → 宽度 / 名字 / 解码分支"这三张表以前是纯手写的, 没有 // 任何东西拦着它漂移 —— 而它已经漂过一次: core 新增 7 个列型 (bool/float32/decimal/ // uuid/year/int128/blob) 时 analyzer 没跟, 后果不是"显示成 ?", 而是: // // - fSz 返回 0 → 行解码 (decodeRows 里的 off += sz) 偏移不推进, 新类型字段【之后 // 的所有列】都从同一个错位偏移解码, 整行静默错位; // - indexcheck 因 size <= 0 直接放弃索引键校验; // - decodeFieldValue 把新类型当定长字符串, 打印出二进制噪声。 // // 现在 fSz/fName 改成投影到 core 的 typeRegistry (漏同步在结构上不可能), 但 // decodeFieldValue 仍是一份手写 switch —— 这两个测试就是它的守卫。 // // 判据一律取自 core 的 types.AllTypes(), 而不是在这里再抄一份期望名单: 抄一份就又 // 造出一个会漂移的副本, 而这回的教训恰恰就是"手写副本没跟着新类型走"。 // TestDecodeCoversEveryRegisteredType 要求 core 注册的每一个列型都被 analyzer 认得出: // 有宽度、有名字、能解码。 func TestDecodeCoversEveryRegisteredType(t *testing.T) { for _, ft := range types.AllTypes() { t.Run(types.TypeNameOf(ft), func(t *testing.T) { sz := fSz(byte(ft)) if want := int(types.GetSize(ft)); sz != want { t.Fatalf("fSz(%d) = %d, core 注册的是 %d", ft, sz, want) } if sz <= 0 { t.Fatalf("fSz(%d) = 0: 行解码靠它推进偏移, 0 会让整行字段错位", ft) } if n := fName(byte(ft)); n == "?" { t.Fatalf("fName(%d) = ?: 该类型在 core 已注册, 名字不该是未知", ft) } // 全零缓冲对每个列型都必须能解出结果 (而不是落到未同步分支)。 // 这里用零值而不是真值, 是为了把"这个类型有没有被处理"和"解出来的值对不对" // 分开: 后者由 TestDecodeNewFieldTypes 用 core 编码的真值来钉。 buf := make([]byte, sz) if got := decodeFieldValue(buf, byte(ft)); got == unknownFieldValue { t.Fatalf("decodeFieldValue 未处理类型 %d (%s): 落到了未同步分支", ft, types.TypeNameOf(ft)) } }) } } // TestDecodeNewFieldTypes 新增的列型必须按盘上语义正确解码。 // // 字节一律由 core 的 types.EncodeValue 生成 —— 测的是"两份独立实现在同一份盘格式下 // 读出来一不一致", 而不是"自己编码再自己解码"的自证 (那种测试实现错了一起错也全绿)。 func TestDecodeNewFieldTypes(t *testing.T) { minInt128, ok := new(big.Int).SetString("-170141183460469231731687303715884105728", 10) if !ok { t.Fatal("minInt128 字面量解析失败") } cases := []struct { name string ft types.FieldType param byte val any want string }{ {"bool true", types.FieldType_Bool, 0, true, "true"}, {"bool false", types.FieldType_Bool, 0, false, "false"}, {"float32 正", types.FieldType_Float32, 0, float32(0.5), "0.5"}, {"float32 负", types.FieldType_Float32, 0, float32(-2.25), "-2.25"}, {"float64 正", types.FieldType_Float64, 0, 0.5, "0.5"}, {"float64 负", types.FieldType_Float64, 0, -2.25, "-2.25"}, {"decimal 两位", types.FieldType_Decimal, 2, "1.50", "1.50"}, {"decimal 负不足一位", types.FieldType_Decimal, 2, "-0.05", "-0.05"}, {"decimal 补零到位", types.FieldType_Decimal, 2, "0", "0.00"}, { "uuid", types.FieldType_UUID, 0, "550e8400-e29b-41d4-a716-446655440000", "550e8400-e29b-41d4-a716-446655440000", }, {"year", types.FieldType_Year, 0, 2026, "2026"}, {"int128 下界", types.FieldType_Int128, 0, minInt128, "-170141183460469231731687303715884105728"}, {"int128 零", types.FieldType_Int128, 0, new(big.Int), "0"}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { buf := make([]byte, types.GetSize(c.ft)) if err := types.EncodeValue(c.ft, c.param, c.val, buf); err != nil { t.Fatalf("core EncodeValue 失败: %v", err) } if got := decodeFieldValue(buf, byte(c.ft)); got != c.want { t.Fatalf("解码 = %q, 期望 %q (盘上字节 %x)", got, c.want, buf) } }) } } // TestDecodeDynamicRef 动态列的行内 6 字节是堆引用, 不是内容。 // // 动态列不走 EncodeValue (内容在堆页, 由 core 解引用后拿到), 所以单独钉。 // 以前它落到 default 分支, 把页号和偏移当字符串打印出来 —— 真正想看的 // "内容在哪一页" 反而看不见。 func TestDecodeDynamicRef(t *testing.T) { // 行内引用布局: [PageID uint32 大端][Offset uint16 大端] if got, want := decodeFieldValue([]byte{0, 0, 0, 5, 0, 12}, byte(types.FieldType_Dynamicbyte)), "(heap page=5 off=12)"; got != want { t.Fatalf("解码 = %q, 期望 %q", got, want) } // PageID == 0 表示空值 if got := decodeFieldValue(make([]byte, 6), byte(types.FieldType_Dynamicbyte)); got != "(empty)" { t.Fatalf("空引用 = %q, 期望 (empty)", got) } } // TestDecodeBlobRef blob 列 (v10) 的行内 8 字节是链引用, 不是内容。 // // 与动态列长得像但读法不同 —— 布局是 [TotalLen uint32 大端][FirstPageID uint32 大端], // 拿动态列那套 (PageID 在前、只有 6 字节) 去读会把长度当页号、还会越界读。 func TestDecodeBlobRef(t *testing.T) { // buf[0:4] = TotalLen = 5, buf[4:8] = FirstPageID = 12 if got, want := decodeFieldValue([]byte{0, 0, 0, 5, 0, 0, 0, 12}, byte(types.FieldType_Blob)), "(blob page=12 len=5)"; got != want { t.Fatalf("解码 = %q, 期望 %q", got, want) } // (TotalLen, FirstPageID) == (0, 0) 表示空 blob if got := decodeFieldValue(make([]byte, 8), byte(types.FieldType_Blob)); got != "(empty)" { t.Fatalf("空引用 = %q, 期望 (empty)", got) } } // TestUnknownFieldTypeIsExplicit 未注册的列型必须显式报未知。 // // 这是上面两个守卫的前提: 如果未知类型也和"正常解码"长得一样 (以前是当定长字符串 // 打印), 那"漏同步"就永远测不出来 —— 输出看着像正常内容, 实际是错的。 func TestUnknownFieldTypeIsExplicit(t *testing.T) { if got := decodeFieldValue([]byte{1, 2, 3, 4}, 0x7E); got != unknownFieldValue { t.Fatalf("未注册类型解码 = %q, 期望 %q", got, unknownFieldValue) } // fSz/fName 对未注册类型同样要给出"未知"而不是假装知道 if got := fSz(0x7E); got != 0 { t.Fatalf("fSz(未注册) = %d, 期望 0", got) } if got := fName(0x7E); got != "?" { t.Fatalf("fName(未注册) = %q, 期望 ?", got) } } // TestFieldLabelShowsRealWidth 类型徽章必须显示【真实行内宽度】。 // // 定长字符串列的行内宽度完全由 Flag 低 5 位的宽度码决定 (类型值只有一个 String) —— 于是 // 同一类型的列可以是 1024 字节宽。徽章上只写类型名, 看起来就是默认的 256 字节, 与 // 盘上差 4 倍; 而"字符串列有多大"恰恰是看 schema 时最想知道的一件事。所以宽字符串列 // 显示成 string(<真实宽度>B)。这里钉住四条边界: // - 宽度码列按真实宽度显示 (与 Size 列同源, 不打架); // - 宽度码为 0 的列保持类型名 (v7 及以前落盘的 schema 低 5 位全为 0, 显示逐字不变); // - decimal 仍显示 scale; // - 其余类型不被改写。 func TestFieldLabelShowsRealWidth(t *testing.T) { wide := types.WidthCodeFor(1024) // 声明 1024 字节 → 向上取到的宽度码 (>0) wideSz := int(types.FieldSize(types.FieldType_String, wide)) if wide == 0 || wideSz < 1024 { t.Fatalf("宽度码 %d / 宽度 %d: core 的宽度表变了, 本测试的前提不成立", wide, wideSz) } cases := []struct { name string typ byte param byte want string }{ {"宽字符串列按真实宽度显示", byte(types.FieldType_String), wide, fmt.Sprintf("string(%dB)", wideSz)}, {"宽度码 0 保持类型名", byte(types.FieldType_String), 0, types.TypeNameOf(types.FieldType_String)}, {"decimal 显示 scale", byte(types.FieldType_Decimal), 2, "decimal(2)"}, {"decimal 无 scale 不加括号", byte(types.FieldType_Decimal), 0, "decimal"}, {"普通类型不被改写", byte(types.FieldType_Float64), 0, types.TypeNameOf(types.FieldType_Float64)}, {"动态列不被改写", byte(types.FieldType_Dynamicbyte), 0, types.TypeNameOf(types.FieldType_Dynamicbyte)}, {"未注册类型", 0x7E, 0, unknownFieldValue}, } for _, c := range cases { sz := int(types.FieldSize(types.FieldType(c.typ), c.param)) if got := fLabel(c.typ, c.param, sz); got != c.want { t.Errorf("%s: fLabel = %q, 期望 %q", c.name, got, c.want) } } // 提示里必须说破"盘上只存宽度码、这是取档后的宽度": 声明值本身没有保留, // 不说破就会有人拿这个数当声明值对账 (声明 1000 会显示成档位 1024)。 if tip := fTip(byte(types.FieldType_String), wide, wideSz); !strings.Contains(tip, "宽度码") { t.Errorf("宽度码列的 tip = %q, 未说明宽度码来源", tip) } // 序列化后必须带着 label / tip: 前端只认 JSON 的 key, 少一个就退回"只显示类型档名", // 而"字符串大小"恰好挂在 label 上。这个契约跨了语言边界, 编译器不会替我们看着。 b, err := json.Marshal(MFInfo{Name: "Wide", Label: "string(1024B)", Tip: "行内宽度 1024 字节"}) if err != nil { t.Fatalf("json.Marshal(MFInfo): %v", err) } for _, k := range []string{`"label":"string(1024B)"`, `"tip":"行内宽度 1024 字节"`} { if !strings.Contains(string(b), k) { t.Errorf("MFInfo 序列化 = %s, 缺 %s —— 前端拿不到就不会显示", b, k) } } } // ============================ schema 格式同步守卫 ============================ // TestParseSchemaAgainstCore analyzer 的 schema 解析必须与 core 写盘的字节逐字节一致。 // // 为什么不在这里手写一份"期望字节/格式": 那测的是"我抄的格式对不对", 抄错了测试也跟着 // 一起错 —— 这次就是这么栽的: 手写的 stride 按 [typ][nameLen][name] 走位, 漏掉了紧跟 // typ 之后的 param 字节, 而没有任何东西拦着它。 // // 所以这里改成让 core **真的建一张含全部列型的表**, 再用 analyzer 自己的读取链路 // (useDB → rHdr → pTabs) 解析同一个文件, 要求两侧对同一份盘上字节的解释一致。 // // 回归目标: 缺 param 字节时 masterFields 会退化成"1 个无名字段", versions 变乱数, // 本测试的第一条断言 (字段个数) 就会失败。 func TestParseSchemaAgainstCore(t *testing.T) { path := "zz_analyzer_schema_guard.db" os.Remove(path) os.Remove(path + ".wal") t.Cleanup(func() { // 先释放 analyzer 读取链路 (useDB) 持有的文件句柄: Windows 上被打开的 // 文件无法删除, 不关句柄清理就会失败 —— 失败被 _ = 吞掉后, 仓库里就会 // 永久残留一个 zz_*.db。 fileMu.Lock() if dbf != nil { dbf.Close() dbf = nil } fileMu.Unlock() retiredMu.Lock() old := retired retired = nil retiredMu.Unlock() for _, f := range old { if f != nil { f.Close() } } if t.Failed() { return // 失败时保留现场, 便于排查 } _ = os.Remove(path) _ = os.Remove(path + ".wal") }) const tableName = "guardtbl" fields := []types.FieldInfo{ {FieldType: types.FieldType_Uint64, FieldName: []byte("_id")}, {FieldType: types.FieldType_String, FieldName: []byte("Name")}, {FieldType: types.FieldType_Bool, FieldName: []byte("Flag")}, {FieldType: types.FieldType_Float32, FieldName: []byte("Ratio")}, {FieldType: types.FieldType_Float64, FieldName: []byte("Wide")}, {FieldType: types.FieldType_Decimal, Flag: 2, FieldName: []byte("Amount")}, {FieldType: types.FieldType_UUID, FieldName: []byte("UID")}, {FieldType: types.FieldType_Year, FieldName: []byte("Yr")}, {FieldType: types.FieldType_Int128, FieldName: []byte("Big")}, {FieldType: types.FieldType_Blob, FieldName: []byte("Body")}, {FieldType: types.FieldType_String, FieldName: []byte("Note")}, // 用宽度码声明出来的宽字符串列: 它的行内宽度 (1024) 不等于类型默认档 (256)。 // 少了这一列, "Sz 忘了带列级参数" 这类漏同步在这个测试里是看不见的。 {FieldType: types.FieldType_String, Flag: types.WidthCodeFor(1024), FieldName: []byte("Wide")}, } db, err := core.Open(path) if err != nil { t.Fatalf("core.Open: %v", err) } if _, err := db.CreateTable(tableName, fields); err != nil { t.Fatalf("core.CreateTable: %v", err) } if err := db.Close(); err != nil { t.Fatalf("core.Close: %v", err) } // 走 analyzer 自己的链路: 切库 → 读 HeaderPage → 遍历表页链 if err := useDB(path); err != nil { t.Fatalf("useDB: %v", err) } invalidateAudit() mu.Lock() rHdr() tabs = pTabs() got := append([]TInfo(nil), tabs...) mu.Unlock() if len(got) != 1 { t.Fatalf("解析出 %d 张表, 期望 1 张", len(got)) } tb := got[0] if tb.Name != tableName { t.Fatalf("表名 = %q, 期望 %q", tb.Name, tableName) } if len(tb.MasterFields) != len(fields) { t.Fatalf("masterFields = %d 个, 期望 %d 个 —— 少读说明字段 stride 与 core 不一致", len(tb.MasterFields), len(fields)) } for i, want := range fields { g := tb.MasterFields[i] if g.Name != string(want.FieldName) { t.Errorf("字段 %d 名 = %q, 期望 %q", i, g.Name, want.FieldName) } if g.Type != byte(want.FieldType) { t.Errorf("字段 %d 类型 = %d, 期望 %d", i, g.Type, want.FieldType) } if g.Flag != want.Flag { t.Errorf("字段 %d flag = %d, 期望 %d (状态位与 decimal scale 都在这里)", i, g.Flag, want.Flag) } if g.Param != want.Param() { t.Errorf("字段 %d 列级参数 = %d, 期望 %d (状态位与列级参数共用一个字节, 取值串位了)", i, g.Param, want.Param()) } // 宽度要按【列级参数】算: 字符串列的宽度码 (maxlen 声明) 决定真实行宽, // 只看类型会一律算成 256, 而盘上那列可能是 1024 —— 行解码 off += sz 全错位。 if wantSz := int(types.FieldSize(want.FieldType, want.Param())); g.Sz != wantSz { t.Errorf("字段 %d 宽度 = %d, 期望 %d (类型 %s, 列级参数 %d)", i, g.Sz, wantSz, types.TypeNameOf(want.FieldType), want.Param()) } // label / tip 是列表里唯一直接显示"这一列多大"的地方 (徽章 + 悬浮说明), // 空掉就等于前端退回只显示类型档名 —— 而宽度码列的档名 (string) 与真实 // 宽度 (1024) 能差 4 倍。所以这里连同 Sz 一起钉住, 保证两者同源。 if g.Label == "" || g.Tip == "" { t.Errorf("字段 %d (%s) 的 label/tip 为空, 列表里没有类型/宽度可显示", i, g.Name) } if !strings.Contains(g.Tip, strconv.Itoa(g.Sz)) { t.Errorf("字段 %d (%s) 的 tip = %q 未提到行内宽度 %d", i, g.Name, g.Tip, g.Sz) } } // 宽度码声明出来的那列, 徽章上必须能看到真实宽度 (1024), 而不是类型档名 (string) —— // 这正是"字符串大小显示在列表里"要落地的那个位置: 类型那一格就得看得见。 wide := tb.MasterFields[len(fields)-1] if wide.Name != "Wide" || !strings.Contains(wide.Label, "1024") { t.Errorf("宽字符串列 %s 的 label = %q, 期望带出真实行内宽度 1024", wide.Name, wide.Label) } // 版本链在 masterFields 之后, 是错位的第一个受害者 —— 单独钉住 if len(tb.Versions) != 1 { t.Fatalf("versions = %d 个, 期望 1 个", len(tb.Versions)) } if tb.Versions[0].Ver != 1 { t.Errorf("主版本号 = %d, 期望 1 —— 版本号是乱数说明 masterFields 之后的字节错位了", tb.Versions[0].Ver) } if n := len(tb.Versions[0].Indices); n != len(fields) { t.Errorf("主版本索引 = %d 个, 期望 %d 个", n, len(fields)) } else { for i, fi := range tb.Versions[0].Indices { if fi != i { // 建表后未动过 schema, 本版字段就是 masterFields 的 0..n-1 t.Errorf("版本字段下标[%d] = %d, 期望 %d", i, fi, i) } } } // VInfo.Indices 必须序列化成【数字数组】: 它是 []byte 时 JSON 会变成 base64 字符串 // ("AAECAwQ="), 前端 v.indices[i] 拿到的是第 i 个字符, masterFields['A'] 恒为 // undefined —— "这一版每列多宽"整块静默消失, 而页面只是少一行悬浮说明, 不报错。 vJSON, err := json.Marshal(tb.Versions[0]) if err != nil { t.Fatalf("json.Marshal(VInfo): %v", err) } if !strings.Contains(string(vJSON), `"indices":[0,1`) { t.Errorf("VInfo 序列化 = %s, 期望 \"indices\" 是数字数组", vJSON) } } // TestParseFlagsAgainstCore 列的可空/唯一声明与索引唯一位的读取。 // // 这三件事各自只藏在盘上的一个位里 (列 flag 的 bit6 / bit5, 索引 flag 的 bit0)。 // 读错了不会报错, 只会把"可空"画成"非空"、把唯一约束画成普通索引 —— 一张表的 // 约束在分析结果里就此消失, 而它恰恰是看 schema 时最想知道的东西。所以这里不重述 // 位布局, 而是用 core 造一张**真库**, 要求 analyzer 的看法与建表时的声明一致。 func TestParseFlagsAgainstCore(t *testing.T) { path := "zz_analyzer_flags_guard.db" os.Remove(path) os.Remove(path + ".wal") t.Cleanup(func() { fileMu.Lock() if dbf != nil { dbf.Close() dbf = nil } fileMu.Unlock() retiredMu.Lock() old := retired retired = nil retiredMu.Unlock() for _, f := range old { if f != nil { f.Close() } } if t.Failed() { return // 失败时保留现场 } _ = os.Remove(path) _ = os.Remove(path + ".wal") }) const tableName = "flagtbl" fields := []types.FieldInfo{ {FieldType: types.FieldType_Uint64, FieldName: []byte("_id")}, {FieldType: types.FieldType_Uint64, FieldName: []byte("Note"), Flag: types.FlagNullable | types.FlagUnique}, {FieldType: types.FieldType_Uint64, FieldName: []byte("Plain")}, {FieldType: types.FieldType_Decimal, FieldName: []byte("Amount"), Flag: 3}, } db, err := core.Open(path) if err != nil { t.Fatalf("core.Open: %v", err) } tbl, err := db.CreateTable(tableName, fields) if err != nil { t.Fatalf("core.CreateTable: %v", err) } // Note 上挂唯一索引 (列声明与索引约束都到位), Plain 上挂普通索引作对照, // 主键索引不带唯一位 (行存储索引的 pkID 自成唯一, 标记是二级索引的事)。 for _, spec := range []struct { name string typ page.IndexType col byte unique bool }{ {"pk_" + tableName, page.IndexTypePrimary, 0, false}, {"idx_note", page.IndexTypeBTree, 1, true}, {"idx_plain", page.IndexTypeBTree, 2, false}, } { if _, err := tbl.CreateIndex(spec.name, spec.typ, []byte{spec.col}, spec.unique); err != nil { t.Fatalf("CreateIndex %s: %v", spec.name, err) } } // 插一行: 索引要有条目才有 EntryPage, 空索引在盘上没有数据页, analyzer 会把 // 它当成"没有索引"(见索引解析里对 ep == 0 的过滤)。要验证的是索引上的唯一位, // 就得让索引真的建起来。Amount (decimal) 不给值, 非空列会落零值。 if _, err := tbl.InsertRow([]types.FieldValue{ {FieldName: []byte("_id"), Value: uint64(1)}, {FieldName: []byte("Note"), Value: uint64(7)}, {FieldName: []byte("Plain"), Value: uint64(9)}, }); err != nil { t.Fatalf("InsertRow: %v", err) } if err := db.Commit(); err != nil { t.Fatalf("core.Commit: %v", err) } if err := db.Close(); err != nil { t.Fatalf("core.Close: %v", err) } // 走 analyzer 自己的链路 if err := useDB(path); err != nil { t.Fatalf("useDB: %v", err) } invalidateAudit() mu.Lock() rHdr() tabs = pTabs() got := append([]TInfo(nil), tabs...) mu.Unlock() if len(got) != 1 { t.Fatalf("解析出 %d 张表, 期望 1 张", len(got)) } tb := got[0] if len(tb.MasterFields) != len(fields) { t.Fatalf("masterFields = %d 个, 期望 %d 个", len(tb.MasterFields), len(fields)) } wantNullable := map[string]bool{"_id": false, "Note": true, "Plain": false, "Amount": false} wantUnique := map[string]bool{"_id": false, "Note": true, "Plain": false, "Amount": false} for _, f := range tb.MasterFields { if f.Nullable != wantNullable[f.Name] { t.Errorf("列 %s 的可空 = %v, 期望 %v (flag=0x%02x)", f.Name, f.Nullable, wantNullable[f.Name], f.Flag) } if f.Unique != wantUnique[f.Name] { t.Errorf("列 %s 的唯一声明 = %v, 期望 %v (flag=0x%02x)", f.Name, f.Unique, wantUnique[f.Name], f.Flag) } if f.Name == "Amount" && f.Param != 3 { t.Errorf("decimal 列的 scale = %d, 期望 3 —— scale 与状态位共用一个字节, 取值串位了", f.Param) } } if len(tb.Idxs) != 3 { t.Fatalf("索引 = %d 个, 期望 3 个: %v", len(tb.Idxs), tb.Idxs) } uniq := map[string]bool{} for _, idx := range tb.Idxs { uniq[idx.Name] = idx.Uniq } if !uniq["idx_note"] { t.Errorf("唯一索引 idx_note 没被认出来 (idx flag 的 bit0): %v", tb.Idxs) } if uniq["idx_plain"] { t.Errorf("普通索引 idx_plain 被错认成唯一: %v", tb.Idxs) } if uniq["pk_"+tableName] { t.Errorf("行存储主键索引不该带唯一位: %v", tb.Idxs) } }