payroll_age_test.go 4.2 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394
  1. // 教龄换算单测(PHP `utils.php:924-935` `calculatingAge()` 口径)。
  2. //
  3. // 三条口径各有一组断言,**任一条写错都会让课酬"稳定地跨错档且不报错"**:
  4. //
  5. // ① 除数固定 365(不是 365.25、不是年份相减)
  6. // ② 四舍五入到 1 位小数(不是 3 位、不是截断)
  7. // ③ 空 / 非法 / 未来日期 → 0
  8. //
  9. // 为什么用固定 `now`:真实 `time.Now()` 会让断言依赖运行时刻(跨零点跑两次结果不同),
  10. // 而教龄恰好要在"整数年边界"上稳定 —— 边界值必须可复现。
  11. package service
  12. import (
  13. "testing"
  14. "time"
  15. )
  16. // dateUTC 构造 UTC 日期(只取年月日,与 `t_teacher_information.teacher_exp` 的 DATE 语义一致)。
  17. func dateUTC(year int, month time.Month, day int) time.Time {
  18. return time.Date(year, month, day, 0, 0, 0, 0, time.UTC)
  19. }
  20. // ptrTime 时间指针(可空列用)。
  21. func ptrTime(t time.Time) *time.Time { return &t }
  22. // TestCalculatingAgeYearsMatchesPHP 教龄换算逐值断言(固定 now,边界可复现)。
  23. func TestCalculatingAgeYearsMatchesPHP(t *testing.T) {
  24. now := dateUTC(2026, time.November, 9)
  25. cases := []struct {
  26. name string
  27. exp *time.Time
  28. want float64
  29. }{
  30. {"nil 起算日 → 0", nil, 0},
  31. {"零值起算日 → 0", ptrTime(time.Time{}), 0},
  32. {"恰好 365 天 → 1.0", ptrTime(now.AddDate(0, 0, -365)), 1.0},
  33. {"恰好 730 天 → 2.0", ptrTime(now.AddDate(0, 0, -730)), 2.0},
  34. // ① 除数口径:313 天 = 0.8575 年 → 0.9。若按"年份相减"会得 **1.0**(2025-12-31 → 2026-11-09)
  35. {"313 天(跨年但不足一年)→ 0.9 而非 1.0", ptrTime(dateUTC(2025, time.December, 31)), 0.9},
  36. // ② 小数位口径:10 天 = 0.0274 年 → **0.0**(按 3 位会是 0.027,按截断也是 0.0 但 100 天会分叉)
  37. {"10 天 → 0.0(1 位小数舍入)", ptrTime(now.AddDate(0, 0, -10)), 0},
  38. {"100 天 → 0.3(0.274 → 0.3)", ptrTime(now.AddDate(0, 0, -100)), 0.3},
  39. {"400 天 → 1.1(1.0959 → 1.1)", ptrTime(now.AddDate(0, 0, -400)), 1.1},
  40. // ③ 未来日期 → 0(PHP 的负时间戳分支)
  41. {"未来日期 → 0", ptrTime(now.AddDate(0, 0, 30)), 0},
  42. }
  43. for _, tc := range cases {
  44. got := calculatingAgeYears(tc.exp, now)
  45. if got != tc.want {
  46. t.Errorf("%s:calculatingAgeYears = %v,期望 %v", tc.name, got, tc.want)
  47. }
  48. }
  49. }
  50. // ⚠ **本测试覆盖不到的一处,写在最前面免得被误解为"已覆盖"**:
  51. // 除数取 `365` 还是 `365.25`,在 `round(x,1)` 之下**对现实教龄区间不可观测**——
  52. // 两者每天只差 1.88e-6 年,要拉出 0.05 年的差异需 26000+ 天(70 年以上)才可能跨舍入边界。
  53. // 我们仍写 `365` 的唯一理由是**逐字照抄 PHP**(`Utils.php:930`),不是因为有断言能抓到;
  54. // 若有人改成 365.25,现有用例**不会红**——记在这里,不假装有防线。
  55. // 真正可观测、且已被用例钉住的是:① 不得用年份相减(见下一条)、② 必须舍入到 1 位、③ 空/未来 → 0。
  56. // TestCalculatingAgeYearsUses365NotCalendar 专门钉住"不得用年份相减"。
  57. //
  58. // 同一个起算日,两种算法给出**不同档位**(0.9 vs 1.0),而 `teacher_exp_min=1.000` 的规则
  59. // 只在后者命中 ⇒ 写错口径会**稳定地跨错档**(且课酬算错、无任何报错)。
  60. func TestCalculatingAgeYearsUses365NotCalendar(t *testing.T) {
  61. now := dateUTC(2026, time.November, 9)
  62. exp := dateUTC(2025, time.December, 31)
  63. // 日历相减:2026-2025 = 1
  64. calendarYears := float64(now.Year() - exp.Year())
  65. got := calculatingAgeYears(ptrTime(exp), now)
  66. if got == calendarYears {
  67. t.Fatalf("教龄 = %v,与「年份相减」结果相同:说明没按 365 天口径算(本例应为 0.9,年份相减是 1.0)", got)
  68. }
  69. if got != 0.9 {
  70. t.Fatalf("教龄 = %v,期望 0.9(313 天 / 365,四舍五入 1 位)", got)
  71. }
  72. }
  73. // TestRoundToDecimal 舍入辅助的边界(0.5 进位按 PHP round 的四舍五入)。
  74. func TestRoundToDecimal(t *testing.T) {
  75. cases := []struct{ in, want float64 }{
  76. {0.5479, 0.5},
  77. {0.55, 0.6},
  78. {1.0959, 1.1},
  79. {2.04, 2.0},
  80. {9.96, 10.0},
  81. }
  82. for _, tc := range cases {
  83. if got := roundToDecimal(tc.in, 1); got != tc.want {
  84. t.Errorf("roundToDecimal(%v,1) = %v,期望 %v", tc.in, got, tc.want)
  85. }
  86. }
  87. }