// academic model 与 DDL 逐列对账单测(**纯单测,不连库**)。 // // 权威基准:testdata/academic-model-ddl.sql(`02-data-model/academic-model-ddl.sql` 的逐字节副本, // 由 AC-01 建立时 cp 而来;DDL 变更须同步刷新该 fixture,AC-17 会再用 information_schema 做真库核对)。 // // 覆盖 4 项硬要求(AC-01 验收②③④): // 1. `AllTables()` 长度 = 27、无重复、每表均有 TableName(); // 2. 27 表 ↔ DDL CREATE TABLE 集合一致(无遗漏/无多余); // 3. 每表列名集合、列类型(VARCHAR(N)/DECIMAL(p,s)/BIGINT/INT/TINYINT(1)/TINYINT/SMALLINT/ // DATETIME(3)/DATE/LONGTEXT)、可空性(NULL ↔ 指针)逐列一致; // 4. 每个字段都有 gorm `column:` tag + 五维中文注释(含 4 个"|"分隔符)。 package model import ( _ "embed" "fmt" "go/ast" "go/parser" "go/token" "os" "reflect" "strings" "sync" "testing" "gorm.io/gorm/schema" ) // ddlSQL academic 目标态 DDL 全文(fixture 副本,见文件头说明)。 // //go:embed testdata/academic-model-ddl.sql var ddlSQL string // expectedTableCount 目标态表数(设计 §2.1:30 旧表 → 27 建 + 3 不建)。 const expectedTableCount = 27 // ddlColumn DDL 单列信息(解析产物)。 type ddlColumn struct { Name string // 列名(DDL 内的 snake_case) Type string // 归一化列类型(大写,如 "VARCHAR(26)" / "DECIMAL(12,2)" / "DATETIME(3)") NotNull bool // 是否 NOT NULL } // TestAllTablesShape 断言 AllTables 的形状:27 项、无重复、TableName 齐全且返回新切片。 func TestAllTablesShape(t *testing.T) { tables := AllTables() if len(tables) != expectedTableCount { t.Fatalf("AllTables() 长度 = %d,期望 %d", len(tables), expectedTableCount) } seen := make(map[string]bool, len(tables)) for _, m := range tables { if m == nil { t.Fatal("AllTables() 含 nil") } p, err := schema.Parse(m, &sync.Map{}, schema.NamingStrategy{}) if err != nil { t.Fatalf("schema.Parse(%T) 失败: %v", m, err) } if p.Table == "" { t.Fatalf("%T 缺少 TableName()", m) } if seen[p.Table] { t.Fatalf("表名重复: %s", p.Table) } seen[p.Table] = true } // 返回新切片:改动第一次返回值不得影响第二次(backend-model-field-spec.md §1.8) first := AllTables() first[0] = nil if second := AllTables(); second[0] == nil { t.Fatal("AllTables() 返回了共享底层数组(调用方可安全 append 的前提被破坏)") } } // TestDDLTableCoverage DDL 中的 CREATE TABLE 集合与 AllTables 完全一致。 func TestDDLTableCoverage(t *testing.T) { ddl := parseDDL(t, ddlSQL) if len(ddl) != expectedTableCount { t.Fatalf("DDL 解析到 %d 张表,期望 %d(fixture 是否最新?)", len(ddl), expectedTableCount) } structTables := make(map[string]bool, expectedTableCount) for _, m := range AllTables() { p, err := schema.Parse(m, &sync.Map{}, schema.NamingStrategy{}) if err != nil { t.Fatalf("schema.Parse 失败: %v", err) } structTables[p.Table] = true } for table := range ddl { if !structTables[table] { t.Fatalf("DDL 表 %s 在 model.AllTables() 中缺失", table) } } for table := range structTables { if _, ok := ddl[table]; !ok { t.Fatalf("model 表 %s 在 DDL 中不存在", table) } } } // TestModelsColumnByColumn 27 表逐列比对(列名/类型/可空性)。 func TestModelsColumnByColumn(t *testing.T) { ddl := parseDDL(t, ddlSQL) checkedColumns := 0 for _, m := range AllTables() { p, err := schema.Parse(m, &sync.Map{}, schema.NamingStrategy{}) if err != nil { t.Fatalf("schema.Parse(%T) 失败: %v", m, err) } cols, ok := ddl[p.Table] if !ok { t.Fatalf("表 %s 无 DDL 定义", p.Table) } // 结构体列索引(DBName → 字段) structCols := make(map[string]*schema.Field, len(p.Fields)) for _, f := range p.Fields { structCols[f.DBName] = f } if len(structCols) != len(p.Fields) { t.Fatalf("表 %s 存在重复 column 映射(DB 列名冲突)", p.Table) } seen := make(map[string]bool, len(cols)) for _, c := range cols { seen[c.Name] = true f, ok := structCols[c.Name] if !ok { t.Fatalf("表 %s 缺少列 %s(DDL 有、model 无)", p.Table, c.Name) } gotType := gormTypeSig(f) if gotType != c.Type { t.Fatalf("表 %s 列 %s 类型 = %s,DDL = %s", p.Table, c.Name, gotType, c.Type) } isPtr := f.FieldType.Kind() == reflect.Ptr if c.NotNull && isPtr { t.Fatalf("表 %s 列 %s 为 NOT NULL,model 却是指针(可空)", p.Table, c.Name) } if !c.NotNull && !isPtr { t.Fatalf("表 %s 列 %s 可空,model 必须指针化(backend-model-field-spec.md §1.5)", p.Table, c.Name) } checkedColumns++ } for name := range structCols { if !seen[name] { t.Fatalf("表 %s 多出列 %s(model 有、DDL 无)", p.Table, name) } } } // 27 表合计约 280+ 列;低于 250 说明解析或映射异常(防御性下限断言) if checkedColumns < 250 { t.Fatalf("仅比对 %d 列,疑似 DDL 解析或列映射不全", checkedColumns) } } // TestFieldTagsAndComments 每个字段必须有 gorm column tag + 五维中文注释(4 个"|"分隔符)。 // // 说明:注释无法经反射读取,故用 go/parser 直接解析本包源文件(同一目录下的 *.go)。 func TestFieldTagsAndComments(t *testing.T) { entries, err := os.ReadDir(".") if err != nil { t.Fatalf("读取包目录失败: %v", err) } fset := token.NewFileSet() files := 0 for _, e := range entries { name := e.Name() if e.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { continue } f, err := parser.ParseFile(fset, name, nil, parser.ParseComments) if err != nil { t.Fatalf("解析 %s 失败: %v", name, err) } files++ for _, decl := range f.Decls { gd, ok := decl.(*ast.GenDecl) if !ok || gd.Tok != token.TYPE { continue } for _, spec := range gd.Specs { ts, ok := spec.(*ast.TypeSpec) if !ok { continue } st, ok := ts.Type.(*ast.StructType) if !ok { continue } for _, field := range st.Fields.List { if len(field.Names) == 0 { continue // 内嵌字段(本包 model 无) } name := field.Names[0].Name tag := "" if field.Tag != nil { tag = field.Tag.Value } if !strings.Contains(tag, "column:") { t.Fatalf("%s.%s 缺少 gorm column tag", ts.Name.Name, name) } comment := "" if field.Comment != nil { comment += field.Comment.Text() } if field.Doc != nil { comment += field.Doc.Text() } if comment == "" { t.Fatalf("%s.%s 缺少五维中文注释", ts.Name.Name, name) } if got := strings.Count(comment, "|"); got < 4 { t.Fatalf("%s.%s 注释维度不足(应含 语义|枚举|写入|关联|红线 共 4 个分隔符,实得 %d):%s", ts.Name.Name, name, got, comment) } } } } } if files < 6 { t.Fatalf("仅解析到 %d 个源文件,期望 >= 6(models_*.go + tables.go)", files) } } // gormTypeSig 把 GORM schema.Field 归一为与 DDL 同形的类型签名。 // // 归一规则(与 backend-model-field-spec.md §1.4 的类型映射互为逆运算): // - `type:decimal(12,2)` / `type:decimal(5,1)` → "DECIMAL(12,2)" / "DECIMAL(5,1)"(原样大写); // - `type:date` → "DATE";`type:longtext` → "LONGTEXT"; // - string + size:N → "VARCHAR(N)";string 无 size → "LONGTEXT"(GORM 默认,正是历史踩坑点); // - bool → "TINYINT(1)";int8/16/32/64 → TINYINT/SMALLINT/INT/BIGINT; // - time.Time / *time.Time → "DATETIME(3)"(MySQL 驱动默认毫秒精度,与 DDL datetime(3) 一致)。 func gormTypeSig(f *schema.Field) string { dt := strings.ToLower(string(f.DataType)) switch { case strings.HasPrefix(dt, "decimal"): if dt == "decimal" { return fmt.Sprintf("DECIMAL(%d,%d)", f.Precision, f.Scale) } return strings.ToUpper(dt) // 已含精度,如 "decimal(12,2)" case dt == "date": return "DATE" case dt == "longtext": return "LONGTEXT" case dt == "datetime(3)": return "DATETIME(3)" } switch f.DataType { case schema.String: if f.Size > 0 { return fmt.Sprintf("VARCHAR(%d)", f.Size) } return "LONGTEXT" case schema.Bool: return "TINYINT(1)" case schema.Int, schema.Uint: switch f.Size { case 8: return "TINYINT" case 16: return "SMALLINT" case 32: return "INT" case 64: return "BIGINT" } return fmt.Sprintf("INT(%d)", f.Size) case schema.Time: if f.Precision > 0 { return fmt.Sprintf("DATETIME(%d)", f.Precision) } return "DATETIME(3)" } return strings.ToUpper(string(f.DataType)) } // parseDDL 解析 DDL 全文,返回「表名 → 列定义列表」(仅列定义,索引/约束行跳过)。 // // 识别规则: // - `CREATE TABLE \`t_x\` (` 行开启一张表; // - 以 ')' 开头的行结束当前表; // - 以反引号开头的行是列定义(`PRIMARY KEY` / `UNIQUE KEY` / `KEY` 开头行自动跳过)。 func parseDDL(t *testing.T, src string) map[string][]ddlColumn { t.Helper() out := make(map[string][]ddlColumn) var cur string for _, raw := range strings.Split(src, "\n") { line := strings.TrimSpace(raw) if line == "" || strings.HasPrefix(line, "--") { continue } if strings.HasPrefix(line, "CREATE TABLE") { start := strings.Index(line, "`") end := strings.LastIndex(line, "`") if start < 0 || end <= start { t.Fatalf("CREATE TABLE 行缺少反引号表名: %s", line) } cur = line[start+1 : end] out[cur] = nil continue } if cur == "" { continue } if strings.HasPrefix(line, ")") { cur = "" continue } if !strings.HasPrefix(line, "`") { continue // PRIMARY KEY / UNIQUE KEY / KEY 等 } rest := line[1:] idx := strings.Index(rest, "`") if idx < 0 { t.Fatalf("列定义行缺少结束反引号: %s", line) } name := rest[:idx] rest = strings.TrimSuffix(strings.TrimSpace(rest[idx+1:]), ",") parts := strings.SplitN(rest, " ", 2) typ := strings.ToUpper(parts[0]) head := rest if i := strings.Index(rest, "COMMENT"); i >= 0 { head = rest[:i] // 排除 COMMENT 文案里出现 "NOT NULL" 造成的误判 } out[cur] = append(out[cur], ddlColumn{ Name: name, Type: typ, NotNull: strings.Contains(head, "NOT NULL"), }) } return out }