payroll_repo_test.go 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452
  1. // PayrollRepo 单测(AC-10):机构级越权守卫 / 方案选取顺序 / 通用档的 NULL teacher 判定 /
  2. // `-1` 通配 / 教龄 BETWEEN / 无档案教师按"无证"处理。
  3. //
  4. // 为什么这些要在仓储层再测一遍(Service 层已覆盖回退链):
  5. // - 回退链的**每一级 SQL 条件**都是"写错也不会报错、只是命中不了"的类型(`IS NULL OR = ""`、
  6. // `IN (x,-1)`、`effect_date <= day`),Service 用例只能证明"某条路径通",
  7. // 无法定位"哪一级条件写错";仓储用例把每个条件单独钉住。
  8. // - `assertInstitution` 是**替代 Scoped** 的越权防线(本仓储不调 Scoped 的理由见文件头),
  9. // 必须单独证明"跨机构会被 1004 拦住"——否则这条防线写不写都没人发现。
  10. package repository
  11. import (
  12. "context"
  13. stderrors "errors"
  14. "path/filepath"
  15. "strings"
  16. "testing"
  17. "time"
  18. gormsqlite "github.com/glebarez/sqlite"
  19. "gorm.io/gorm"
  20. "yuansiwei/unified-backend/internal/academic/model"
  21. infraerrors "yuansiwei/unified-backend/internal/infra/errors"
  22. appscope "yuansiwei/unified-backend/internal/infra/scope"
  23. )
  24. // newPayrollRepoDB 课酬 4 表文件库。
  25. func newPayrollRepoDB(t *testing.T) *gorm.DB {
  26. t.Helper()
  27. dsn := filepath.Join(t.TempDir(), "academic_payroll_repo_test.db")
  28. db, err := gorm.Open(gormsqlite.Open(dsn), &gorm.Config{})
  29. if err != nil {
  30. t.Fatalf("gorm.Open(课酬文件库) 失败: %v", err)
  31. }
  32. t.Cleanup(func() {
  33. if sqlDB, e := db.DB(); e == nil {
  34. _ = sqlDB.Close()
  35. }
  36. })
  37. migrateTablesDroppingPlainIndexes(t, db,
  38. &model.CoursePayment{}, &model.CoursePaymentDetail{},
  39. &model.CoursesClassify{}, &model.TeacherInformation{},
  40. )
  41. return db
  42. }
  43. // migrateTablesDroppingPlainIndexes 逐表 AutoMigrate,并在每张表后删掉**非唯一**索引。
  44. //
  45. // 为什么需要它(与 service 包的 migrateFundTables 同因):SQLite 的索引名是**库级唯一**,
  46. // 而 academic model 多张表共用 `idx_payment` / `idx_inst_campus` 等名字,一次性 AutoMigrate
  47. // 会报 "index already exists"。唯一约束**必须保留**(业务 UK),故只删非 UNIQUE 索引。
  48. func migrateTablesDroppingPlainIndexes(t *testing.T, db *gorm.DB, tables ...any) {
  49. t.Helper()
  50. for _, tbl := range tables {
  51. if err := db.AutoMigrate(tbl); err != nil {
  52. t.Fatalf("AutoMigrate(%T) 失败: %v", tbl, err)
  53. }
  54. stmt := &gorm.Statement{DB: db}
  55. if err := stmt.Parse(tbl); err != nil {
  56. t.Fatalf("解析表名(%T) 失败: %v", tbl, err)
  57. }
  58. var rows []struct {
  59. Name string
  60. SQL *string
  61. }
  62. if err := db.Raw(
  63. "SELECT name, sql FROM sqlite_master WHERE type = 'index' AND tbl_name = ? AND name NOT LIKE 'sqlite_%'",
  64. stmt.Schema.Table).Scan(&rows).Error; err != nil {
  65. t.Fatalf("读取索引清单(%s) 失败: %v", stmt.Schema.Table, err)
  66. }
  67. for _, row := range rows {
  68. sqlText := ""
  69. if row.SQL != nil {
  70. sqlText = strings.ToUpper(*row.SQL)
  71. }
  72. if strings.Contains(sqlText, "UNIQUE") {
  73. continue // 唯一约束必须保留(业务 UK / 幂等键)
  74. }
  75. if err := db.Exec("DROP INDEX IF EXISTS `" + row.Name + "`").Error; err != nil {
  76. t.Fatalf("删除索引(%s) 失败: %v", row.Name, err)
  77. }
  78. }
  79. }
  80. }
  81. // payrollRepoScope 普通角色作用域(机构 1 / 校区 10)。
  82. func payrollRepoScope() *appscope.Scope {
  83. return &appscope.Scope{InstitutionID: 1, CampusID: 10, UserID: "01HOPERATOR0000000000001", Role: "academic"}
  84. }
  85. // insertScheme 落一行课酬方案。
  86. func insertScheme(t *testing.T, db *gorm.DB, id, inst, campus int64, effect time.Time, actived bool) {
  87. t.Helper()
  88. eff := effect
  89. row := &model.CoursePayment{ID: id, InstitutionID: inst, CampusID: campus, EffectDate: &eff, Actived: actived}
  90. if err := db.Create(row).Error; err != nil {
  91. t.Fatalf("seed 方案(%d) 失败: %v", id, err)
  92. }
  93. }
  94. // insertDetail 落一行课酬明细(teacherID=nil 表示通用档)。
  95. func insertDetail(t *testing.T, db *gorm.DB, id, schemeID int64, teacherID *string, cert bool,
  96. classType, gradeID, subjectID int32, expMin, expMax string) {
  97. t.Helper()
  98. row := &model.CoursePaymentDetail{
  99. ID: id, CoursePaymentID: schemeID, TeacherID: teacherID, TeacherCert: cert,
  100. ClassType: classType, GradeID: gradeID, SubjectID: subjectID,
  101. Percentage: "50.0", MinPayment: "0.00", MaxPayment: "0.00", Reward: "0.00",
  102. TeacherExpMin: expMin, TeacherExpMax: expMax, Actived: true,
  103. }
  104. if err := db.Create(row).Error; err != nil {
  105. t.Fatalf("seed 明细(%d) 失败: %v", id, err)
  106. }
  107. }
  108. // TestPayrollRepoAssertInstitution 机构级越权守卫(替代 Scoped 的那道防线)。
  109. func TestPayrollRepoAssertInstitution(t *testing.T) {
  110. db := newPayrollRepoDB(t)
  111. ctx := context.Background()
  112. insertScheme(t, db, 1, 2, 10, time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), true)
  113. day := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
  114. // 普通角色查其它机构 → 1004(**不是** ErrNotFound,越权必须显式拒绝)
  115. repo := NewPayrollRepo(db, payrollRepoScope())
  116. _, err := repo.FindActiveScheme(ctx, 2, 10, day)
  117. wantRepoErr(t, err, infraerrors.CodeScopeViolation, "跨机构查方案")
  118. // 超管 → 放行(可查机构 2)
  119. admin := &appscope.Scope{InstitutionID: 1, SuperAdmin: true, UserID: "01HADMIN0000000000000001"}
  120. got, err := NewPayrollRepo(db, admin).FindActiveScheme(ctx, 2, 10, day)
  121. if err != nil {
  122. t.Fatalf("超管查机构 2 失败: %v", err)
  123. }
  124. if got == nil || got.ID != 1 {
  125. t.Fatalf("超管应命中方案 1,实得 %+v", got)
  126. }
  127. // 系统任务视角(Sc 为 nil)→ 放行
  128. if _, err := NewPayrollRepo(db, nil).FindActiveScheme(ctx, 2, 10, day); err != nil {
  129. t.Fatalf("nil 作用域应放行: %v", err)
  130. }
  131. // 机构 <=0 → 2001(机构是必填维度,0 会让查询跨机构)
  132. _, err = repo.FindActiveScheme(ctx, 0, 10, day)
  133. wantRepoErr(t, err, infraerrors.CodeBadParam, "机构为 0")
  134. }
  135. // TestPayrollRepoFindActiveScheme 方案选取:仅 actived、仅 `effect_date <= day`(含当日)、取最新。
  136. func TestPayrollRepoFindActiveScheme(t *testing.T) {
  137. db := newPayrollRepoDB(t)
  138. ctx := context.Background()
  139. repo := NewPayrollRepo(db, payrollRepoScope())
  140. base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
  141. insertScheme(t, db, 11, 1, 10, base, true) // 生效
  142. insertScheme(t, db, 12, 1, 10, base.AddDate(0, 3, 0), true) // 更晚生效
  143. insertScheme(t, db, 13, 1, 10, base, false) // 停用 → 不可命中
  144. insertScheme(t, db, 14, 1, 10, base.AddDate(1, 0, 0), true) // 未来生效 → 当日不可命中
  145. insertScheme(t, db, 15, 1, 0, base, true) // 机构级方案
  146. // 试算日 = 2026-05-20 → 校区方案里最新的(<=日) 是 12
  147. got, err := repo.FindActiveScheme(ctx, 1, 10, time.Date(2026, 5, 20, 9, 30, 0, 0, time.UTC))
  148. if err != nil {
  149. t.Fatalf("查校区方案失败: %v", err)
  150. }
  151. if got == nil || got.ID != 12 {
  152. t.Fatalf("命中方案 = %+v,期望 12(effect_date 最新且已生效)", got)
  153. }
  154. // **含当日**:试算日恰为生效日 00:00 → 命中 11/12 中较新的 12
  155. got, err = repo.FindActiveScheme(ctx, 1, 10, time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC))
  156. if err != nil {
  157. t.Fatalf("查当日生效方案失败: %v", err)
  158. }
  159. if got == nil || got.ID != 12 {
  160. t.Fatalf("当日生效应命中 12,实得 %+v", got)
  161. }
  162. // 早于任何方案生效日 → nil(不是 ErrNotFound)
  163. got, err = repo.FindActiveScheme(ctx, 1, 10, time.Date(2025, 12, 31, 0, 0, 0, 0, time.UTC))
  164. if err != nil {
  165. t.Fatalf("早期查询不应报错: %v", err)
  166. }
  167. if got != nil {
  168. t.Fatalf("应返回 nil(无生效方案),实得 %+v", got)
  169. }
  170. // 校区 20 无方案 → nil;机构级(campus 0)存在 → 命中 15
  171. got, err = repo.FindActiveScheme(ctx, 1, 20, time.Date(2026, 5, 20, 0, 0, 0, 0, time.UTC))
  172. if err != nil || got != nil {
  173. t.Fatalf("校区 20 应无方案,实得 %+v / %v", got, err)
  174. }
  175. got, err = repo.FindActiveScheme(ctx, 1, 0, time.Date(2026, 5, 20, 0, 0, 0, 0, time.UTC))
  176. if err != nil {
  177. t.Fatalf("查机构级方案失败: %v", err)
  178. }
  179. if got == nil || got.ID != 15 {
  180. t.Fatalf("机构级应命中 15,实得 %+v", got)
  181. }
  182. }
  183. // TestPayrollRepoTeacherHasCert 无资质档案 = 无证(dump 的 `IF NULL>0` 不成立),**不是错误**。
  184. func TestPayrollRepoTeacherHasCert(t *testing.T) {
  185. db := newPayrollRepoDB(t)
  186. ctx := context.Background()
  187. repo := NewPayrollRepo(db, payrollRepoScope())
  188. cert, err := repo.TeacherHasCert(ctx, "01HTEACHER000000000000001")
  189. if err != nil {
  190. t.Fatalf("无档案不应报错: %v", err)
  191. }
  192. if cert {
  193. t.Fatal("无档案应判为无证")
  194. }
  195. if err := db.Create(&model.TeacherInformation{
  196. TeacherID: "01HTEACHER000000000000002", TeacherCert: true,
  197. }).Error; err != nil {
  198. t.Fatalf("seed 教师资质失败: %v", err)
  199. }
  200. cert, err = repo.TeacherHasCert(ctx, "01HTEACHER000000000000002")
  201. if err != nil {
  202. t.Fatalf("持证教师查询失败: %v", err)
  203. }
  204. if !cert {
  205. t.Fatal("持证教师应判为持证")
  206. }
  207. _, err = repo.TeacherHasCert(ctx, "")
  208. wantRepoErr(t, err, infraerrors.CodeBadParam, "空 teacher_id")
  209. }
  210. // TestPayrollRepoGeneralRuleMatchesNullTeacher 通用档必须匹配 `teacher_id IS NULL`,且不得匹配个人档。
  211. func TestPayrollRepoGeneralRuleMatchesNullTeacher(t *testing.T) {
  212. db := newPayrollRepoDB(t)
  213. ctx := context.Background()
  214. repo := NewPayrollRepo(db, payrollRepoScope())
  215. teacher := "01HTEACHER000000000000010"
  216. insertDetail(t, db, 21, 1, nil, true, 1000, -1, -1, "0.00", "5.00") // 持证通用
  217. insertDetail(t, db, 22, 1, &teacher, false, 1000, -1, -1, "0.00", "5.00") // 个人档(**不得**被通用查询命中)
  218. cert, err := repo.FindGeneralRule(ctx, 1, true, 1000, 7, 3, "2.00")
  219. if err != nil {
  220. t.Fatalf("查持证通用档失败: %v", err)
  221. }
  222. if cert == nil || cert.ID != 21 {
  223. t.Fatalf("持证通用档应命中 21,实得 %+v", cert)
  224. }
  225. // 无证通用档:没有对应行 → nil(不能因为"个人档存在"就误命中 22)
  226. noCert, err := repo.FindGeneralRule(ctx, 1, false, 1000, 7, 3, "2.00")
  227. if err != nil {
  228. t.Fatalf("查无证通用档失败: %v", err)
  229. }
  230. if noCert != nil {
  231. t.Fatalf("无证通用档不应命中个人档 22,实得 %+v", noCert)
  232. }
  233. }
  234. // TestPayrollRepoGeneralRuleWildcardAndExp 年级/科目 `-1` 通配 + 教龄闭区间 + 具体行优先。
  235. func TestPayrollRepoGeneralRuleWildcardAndExp(t *testing.T) {
  236. db := newPayrollRepoDB(t)
  237. ctx := context.Background()
  238. repo := NewPayrollRepo(db, payrollRepoScope())
  239. insertDetail(t, db, 31, 2, nil, false, 1000, -1, -1, "0.00", "1.00") // 全通配,教龄 0~1
  240. insertDetail(t, db, 32, 2, nil, false, 1000, 7, -1, "0.00", "1.00") // 年级精确 7,科目通配
  241. insertDetail(t, db, 33, 2, nil, false, 1000, 7, 3, "3.00", "5.00") // 年级+科目精确,教龄 3~5
  242. // 分类 7/3、教龄 0.50(31/32 的 0~1 档都满足,33 的 3~5 档不满足)
  243. // → `ORDER BY grade_id DESC` 让"年级精确 7"的 32 排在"年级 -1 通配"的 31 之前
  244. got, err := repo.FindGeneralRule(ctx, 2, false, 1000, 7, 3, "0.50")
  245. if err != nil {
  246. t.Fatalf("查通用档失败: %v", err)
  247. }
  248. if got == nil || got.ID != 32 {
  249. t.Fatalf("应命中 32(年级精确优先于 -1 通配),实得 %+v", got)
  250. }
  251. // 分类 9/3(年级 9 不匹配 7,但匹配 -1)→ 命中 31
  252. got, err = repo.FindGeneralRule(ctx, 2, false, 1000, 9, 3, "0.50")
  253. if err != nil {
  254. t.Fatalf("查通配档失败: %v", err)
  255. }
  256. if got == nil || got.ID != 31 {
  257. t.Fatalf("应命中 31(-1 通配),实得 %+v", got)
  258. }
  259. // 教龄 3.00 落在 3.00~5.00 → 命中 33(闭区间下界)
  260. got, err = repo.FindGeneralRule(ctx, 2, false, 1000, 7, 3, "3.00")
  261. if err != nil {
  262. t.Fatalf("查教龄下界失败: %v", err)
  263. }
  264. if got == nil || got.ID != 33 {
  265. t.Fatalf("教龄 3.00 应命中 33,实得 %+v", got)
  266. }
  267. // 教龄 5.00(闭区间上界)→ 仍命中 33
  268. got, err = repo.FindGeneralRule(ctx, 2, false, 1000, 7, 3, "5.00")
  269. if err != nil {
  270. t.Fatalf("查教龄上界失败: %v", err)
  271. }
  272. if got == nil || got.ID != 33 {
  273. t.Fatalf("教龄 5.00 应命中 33(闭区间),实得 %+v", got)
  274. }
  275. // 教龄 5.01 → 所有档都不满足 → nil
  276. got, err = repo.FindGeneralRule(ctx, 2, false, 1000, 7, 3, "5.01")
  277. if err != nil {
  278. t.Fatalf("查越界教龄失败: %v", err)
  279. }
  280. if got != nil {
  281. t.Fatalf("教龄 5.01 不应命中,实得 %+v", got)
  282. }
  283. }
  284. // TestPayrollRepoPersonalQueries 个人档两级的条件差异(分类精确 vs 年级/科目通配)。
  285. func TestPayrollRepoPersonalQueries(t *testing.T) {
  286. db := newPayrollRepoDB(t)
  287. ctx := context.Background()
  288. repo := NewPayrollRepo(db, payrollRepoScope())
  289. teacher := "01HTEACHER000000000000020"
  290. // 分类精确档(classify_id=7001)
  291. insertDetail(t, db, 41, 3, &teacher, false, 1000, 7, 3, "0.00", "9.00")
  292. if err := db.Model(&model.CoursePaymentDetail{}).Where("id = ?", 41).
  293. Update("classify_id", 7001).Error; err != nil {
  294. t.Fatalf("设置 classify_id 失败: %v", err)
  295. }
  296. count, err := repo.CountPersonalRules(ctx, 3, teacher, 1000)
  297. if err != nil {
  298. t.Fatalf("统计个人规则失败: %v", err)
  299. }
  300. if count != 1 {
  301. t.Fatalf("个人规则条数 = %d,期望 1", count)
  302. }
  303. // 班型不同 → 闸门关闭(count=0)
  304. count, err = repo.CountPersonalRules(ctx, 3, teacher, 2000)
  305. if err != nil {
  306. t.Fatalf("统计个人规则(其它班型)失败: %v", err)
  307. }
  308. if count != 0 {
  309. t.Fatalf("其它班型不应计入个人规则,实得 %d", count)
  310. }
  311. byClassify, err := repo.FindPersonalByClassify(ctx, 3, teacher, 1000, 7001)
  312. if err != nil {
  313. t.Fatalf("查分类档失败: %v", err)
  314. }
  315. if byClassify == nil || byClassify.ID != 41 {
  316. t.Fatalf("分类精确档应命中 41,实得 %+v", byClassify)
  317. }
  318. // 分类不匹配 → nil(`classify_id=0` 不当通配,dump 是本档精确等值)
  319. byOtherClassify, err := repo.FindPersonalByClassify(ctx, 3, teacher, 1000, 7002)
  320. if err != nil {
  321. t.Fatalf("查其它分类档失败: %v", err)
  322. }
  323. if byOtherClassify != nil {
  324. t.Fatalf("其它分类不应命中,实得 %+v", byOtherClassify)
  325. }
  326. // 年级/科目档仍可命中(同一行同时满足 7/3)
  327. byGrade, err := repo.FindPersonalByGradeSubject(ctx, 3, teacher, 1000, 7, 3)
  328. if err != nil {
  329. t.Fatalf("查年级/科目档失败: %v", err)
  330. }
  331. if byGrade == nil || byGrade.ID != 41 {
  332. t.Fatalf("年级/科目档应命中 41,实得 %+v", byGrade)
  333. }
  334. }
  335. // TestPayrollRepoGetClassify 分类单查:未命中 → 3001;id<=0 → 2001。
  336. func TestPayrollRepoGetClassify(t *testing.T) {
  337. db := newPayrollRepoDB(t)
  338. ctx := context.Background()
  339. repo := NewPayrollRepo(db, payrollRepoScope())
  340. if err := db.Create(&model.CoursesClassify{
  341. ID: 7001, InstitutionID: 1, CampusID: 10, ClassType: 1000, GradeID: 7, SubjectID: 3, Name: "分类",
  342. }).Error; err != nil {
  343. t.Fatalf("seed 分类失败: %v", err)
  344. }
  345. got, err := repo.GetClassify(ctx, 7001)
  346. if err != nil {
  347. t.Fatalf("查分类失败: %v", err)
  348. }
  349. if got.GradeID != 7 || got.SubjectID != 3 || got.ClassType != 1000 {
  350. t.Fatalf("分类维度 = (%d,%d,%d),期望 (7,3,1000)", got.GradeID, got.SubjectID, got.ClassType)
  351. }
  352. _, err = repo.GetClassify(ctx, 9999)
  353. wantRepoErr(t, err, infraerrors.CodeNotFound, "分类不存在")
  354. _, err = repo.GetClassify(ctx, 0)
  355. wantRepoErr(t, err, infraerrors.CodeBadParam, "分类 id 为 0")
  356. }
  357. // wantRepoErr 仓储层错误码断言(仓储把 gorm 原错统一转 sentinel)。
  358. func wantRepoErr(t *testing.T, err error, code int, what string) {
  359. t.Helper()
  360. if err == nil {
  361. t.Fatalf("%s:期望错误码 %d,实得 nil", what, code)
  362. }
  363. var appErr *infraerrors.AppError
  364. if !stderrors.As(err, &appErr) {
  365. t.Fatalf("%s:期望 *AppError(%d),实得 %T(%v)", what, code, err, err)
  366. }
  367. if appErr.Code != code {
  368. t.Fatalf("%s:期望错误码 %d,实得 %d(%s)", what, code, appErr.Code, appErr.Msg)
  369. }
  370. }
  371. // TestPayrollRepoTeacherExpThreeDecimalScale 教龄列保留 3 位精度时,区间边界的归属必须正确(P0 回归)。
  372. //
  373. // 场景(对齐 dump 首行 `0.000 / 0.999` 与下一档 `1.000 / 1.999`):
  374. // 教龄恰为 `1.000` 时**只能**命中 `[1.000, 1.999]`,不得命中 `[0.000, 0.999]`。
  375. //
  376. // 两种失败形态都能被本用例抓住:
  377. // 1. **列精度被收敛**(`decimal(6,3)` → `(6,2)`):MySQL 侧 `0.999` 会存成 `1.00`,两档并列命中,
  378. // `id ASC` 取到低档 ⇒ 本用例红。⚠ sqlite 不强制 scale,故这一形态在本仓由
  379. // `model.TestModelsColumnByColumn`(tag ↔ DDL 逐列比对)接力守。
  380. // 2. **比较运算符写错**(`<=`/`>=` 写成 `<`/`>`,或把 BETWEEN 拆成两段错一个):边界值直接漏/多 ⇒ 立刻红。
  381. func TestPayrollRepoTeacherExpThreeDecimalScale(t *testing.T) {
  382. db := newPayrollRepoDB(t)
  383. ctx := context.Background()
  384. repo := NewPayrollRepo(db, payrollRepoScope())
  385. insertDetail(t, db, 1, 77, nil, false, 1000, -1, -1, "0.000", "0.999")
  386. insertDetail(t, db, 91, 77, nil, false, 1000, -1, -1, "1.000", "1.999")
  387. cases := []struct {
  388. exp string
  389. want int64
  390. why string
  391. }{
  392. {"1.000", 91, "整数年只命中上一档(低档上限 0.999 不含 1.0)"},
  393. {"0.999", 1, "上界闭区间:0.999 ∈ 低档"},
  394. {"1.999", 91, "上界闭区间:1.999 ∈ 高档"},
  395. {"0.000", 1, "下界闭区间:0 ∈ 低档"},
  396. {"2.000", 0, "出界:不应命中任何档"},
  397. }
  398. for _, tc := range cases {
  399. got, err := repo.FindGeneralRule(ctx, 77, false, 1000, 7, 3, tc.exp)
  400. if err != nil {
  401. t.Fatalf("教龄 %s 查询失败: %v", tc.exp, err)
  402. }
  403. if tc.want == 0 {
  404. if got != nil {
  405. t.Fatalf("教龄 %s 应无命中,实得 %+v(%s)", tc.exp, got, tc.why)
  406. }
  407. continue
  408. }
  409. if got == nil || got.ID != tc.want {
  410. t.Fatalf("教龄 %s 命中 %+v,期望 detail=%d(%s)", tc.exp, got, tc.want, tc.why)
  411. }
  412. }
  413. }