Files
cpdaily_xiaozhushou/backed/internal/service/cpdaily/sign_window_test.go
T
becomer 7cbbbff3ea
ci/woodpecker/tag/build Pipeline was successful
按签到 H5 真实逻辑补齐时间段与更新签到判定
从线上签到 H5 拿到源码(counselor-front.campusphere.net/...
wec-counselor-sign-apps/prod/prod_v2.1.1.1357_4DnD/stu/mobile/js/app.js),
按其中实现重写判定,不再依赖推测。

H5 关键实现:
  xt = { sign: 1, noSign: 2, leave: 5 }        // signStatus 枚举
  Rt = { noStart, running, finished }          // 任务阶段
  $t(): isLoop 时把 signDate.substring(0,10) 与时刻拼接,否则用时刻本身
  Gt(): current > end → finished
        current <= begin → noStart
        否则 → running
  handlerButtonClick: if (signed && !isAllowUpdate) toast("更新签到开关已关闭")
  updateSign: !updateLimit || leftNum 才允许提交,否则提示"已超出最大更新次数"

对补签的结论:签到 H5 全量 JS 中不存在 supply / 补签 字样。
所谓"补签"在 H5 里就是"更新签到"——已签到后再次提交,
受 isAllowUpdate 与 updateLimit/leftNum 控制。
查寝详情另有 supplyTime 字段(实测为空串),但签到 H5 未使用它。

改动:
- SignWindow/ParseSignWindow:singleTask*(查寝,完整日期时间)优先,
  否则用 rateSignDate + rateTaskBeginTime/EndTime(sign、return_register);
  解析失败一律 Parsed=false 放行,避免时间格式差异误杀
- EvalTaskPhase:复现 Gt(),含"等于开始时刻算 noStart"的边界
- SignUpdateBlocked:已签到且 isAllowUpdate=false → 拦截;
  updateLimit=true 且 leftNum<=0 → 拦截(次数用尽)
- handler 在执行前先查"已签到不允许更新",再查时间段,
  两者拦截时都写 fail 状态、记日志、发 task_error 事件且不扣次数
- 时间字段实测:sign/return_register 用 rateSignDate+时刻,
  sleep_check 用 singleTaskBeginTime/EndTime 完整时间

测试覆盖:两种时间格式解析、时段内/外/边界、信息缺失不拦截、
signStatus 枚举各值、次数限制、EvalTaskPhase 三态与边界。
2026-10-19 11:40:17 +08:00

250 lines
7.8 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 cpdaily
import (
"errors"
"testing"
"time"
)
// 真实的 detailSignInstance rows 片段(学号 2364400108 实测)。
func realRows() map[string]interface{} {
return map[string]interface{}{
"taskName": "2026(1)节假日返校签到",
"rateSignDate": "2026-10-25 (周日)",
"rateTaskBeginTime": "18:30",
"rateTaskEndTime": "23:00",
"currentTime": "2026-10-19 11:31:21",
"signStatus": "2",
"taskType": float64(6),
}
}
func TestParseSignWindowReal(t *testing.T) {
w := ParseSignWindow(realRows())
if !w.Parsed {
t.Fatal("应能解析出时间段")
}
if w.Start.IsZero() || w.End.IsZero() {
t.Fatal("起止时间不应为零值")
}
t.Logf("窗口: %s ~ %s (date=%s)", w.Start, w.End, w.Date)
wantStart := time.Date(2026, 10, 25, 18, 30, 0, 0, time.Local)
wantEnd := time.Date(2026, 10, 25, 23, 0, 0, 0, time.Local)
if !w.Start.Equal(wantStart) {
t.Fatalf("开始时间错: got %s want %s", w.Start, wantStart)
}
if !w.End.Equal(wantEnd) {
t.Fatalf("结束时间错: got %s want %s", w.End, wantEnd)
}
}
func TestCheckSignWindowReal(t *testing.T) {
rows := realRows()
// 实测当时时间:10-19 11:31,早于 10-25 18:30 → 应拦截
now := time.Date(2026, 10, 19, 11, 31, 21, 0, time.Local)
err := CheckSignWindow(rows, now)
if err == nil {
t.Fatal("不在时间段内应返回错误")
}
if !errors.Is(err, ErrOutsideSignWindow) {
t.Fatalf("错误应可用 errors.Is 识别,实际: %v", err)
}
t.Logf("拦截提示: %v", err)
// 时段内:应放行
inWin := time.Date(2026, 10, 25, 20, 0, 0, 0, time.Local)
if err := CheckSignWindow(rows, inWin); err != nil {
t.Fatalf("时段内不应拦截: %v", err)
}
// 边界:开始时刻与结束时刻本身应算在窗口内
if err := CheckSignWindow(rows, wantTime(2026, 10, 25, 18, 30)); err != nil {
t.Fatalf("开始时刻应放行: %v", err)
}
if err := CheckSignWindow(rows, wantTime(2026, 10, 25, 23, 0)); err != nil {
t.Fatalf("结束时刻应放行: %v", err)
}
// 刚过结束一分钟:应拦截
if err := CheckSignWindow(rows, wantTime(2026, 10, 25, 23, 1)); err == nil {
t.Fatal("超时后应拦截")
}
}
func wantTime(y int, m time.Month, d, hh, mm int) time.Time {
return time.Date(y, m, d, hh, mm, 0, 0, time.Local)
}
// 时间信息缺失时不应拦截,避免误杀。
func TestCheckSignWindowNoInfo(t *testing.T) {
cases := []map[string]interface{}{
nil,
{},
{"taskName": "x"},
{"rateSignDate": "2026-10-25 (周日)"}, // 缺 begin/end
{"rateTaskBeginTime": "18:30", "rateTaskEndTime": "23:00"}, // 缺日期
{"rateSignDate": "bad-date", "rateTaskBeginTime": "x", "rateTaskEndTime": "y"},
}
for i, rows := range cases {
if err := CheckSignWindow(rows, time.Now()); err != nil {
t.Fatalf("case %d 信息不足时不应拦截: %v", i, err)
}
}
}
// singleTask* 存在时优先使用(单次任务精确区间)。
func TestParseSignWindowSingleTaskPriority(t *testing.T) {
rows := map[string]interface{}{
"rateSignDate": "2026-10-25 (周日)",
"rateTaskBeginTime": "18:30",
"rateTaskEndTime": "23:00",
"singleTaskBeginTime": "2026-10-19 08:00:00",
"singleTaskEndTime": "2026-10-19 12:00:00",
}
w := ParseSignWindow(rows)
if !w.Parsed {
t.Fatal("应解析成功")
}
if w.Start.Hour() != 8 || w.Start.Day() != 19 {
t.Fatalf("应优先使用 singleTaskBeginTime,实际 %s", w.Start)
}
// 在该区间内应放行
if err := CheckSignWindow(rows, wantTime(2026, 10, 19, 10, 0)); err != nil {
t.Fatalf("单次任务区间内应放行: %v", err)
}
}
// 查寝(sleep_check)实测字段:singleTask* 是完整日期时间。
func sleepCheckRows() map[string]interface{} {
return map[string]interface{}{
"rateSignDate": "2026-10-19 (周一)",
"rateTaskBeginTime": "22:30",
"rateTaskEndTime": "23:30",
"singleTaskBeginTime": "2026-10-19 22:30",
"singleTaskEndTime": "2026-10-19 23:30",
"iotEndTime": "23:30",
"supplyTime": "",
"signStatus": "0",
"currentTime": "2026-10-19 11:37:32",
}
}
func TestParseSignWindowSleepCheck(t *testing.T) {
w := ParseSignWindow(sleepCheckRows())
if !w.Parsed {
t.Fatal("查寝应能解析出时间段")
}
wantStart := wantTime(2026, 10, 19, 22, 30)
wantEnd := wantTime(2026, 10, 19, 23, 30)
if !w.Start.Equal(wantStart) || !w.End.Equal(wantEnd) {
t.Fatalf("解析错: %s ~ %s", w.Start, w.End)
}
// 当日上午 → 应拦截
if err := CheckSignWindow(sleepCheckRows(), wantTime(2026, 10, 19, 11, 37)); err == nil {
t.Fatal("上午应拦截")
}
// 时段内 → 放行
if err := CheckSignWindow(sleepCheckRows(), wantTime(2026, 10, 19, 23, 0)); err != nil {
t.Fatalf("时段内应放行: %v", err)
}
}
// return_register 与 sign 共用 rateSignDate + rateTaskBeginTime 组合格式。
func TestParseSignWindowReturnRegister(t *testing.T) {
rows := map[string]interface{}{
"rateSignDate": "2026-10-25 (周日)",
"rateTaskBeginTime": "18:30",
"rateTaskEndTime": "23:00",
"signStatus": "2",
"singleTaskBeginTime": nil,
"singleTaskEndTime": nil,
}
w := ParseSignWindow(rows)
if !w.Parsed {
t.Fatal("返校签到应能解析")
}
if !w.Start.Equal(wantTime(2026, 10, 25, 18, 30)) {
t.Fatalf("解析错: %s", w.Start)
}
}
// signStatus 枚举实测自 H5:xt = { sign:1, noSign:2, leave:5 }
func TestSignUpdateBlocked(t *testing.T) {
cases := []struct {
name string
rows map[string]interface{}
blocked bool
}{
{
name: "未签到(signStatus=2) → 不拦截",
rows: map[string]interface{}{"signStatus": "2", "isAllowUpdate": false},
blocked: false,
},
{
name: "已签到且禁止更新 → 拦截",
rows: map[string]interface{}{"signStatus": "1", "isAllowUpdate": false},
blocked: true,
},
{
name: "已签到但允许更新 → 不拦截",
rows: map[string]interface{}{"signStatus": "1", "isAllowUpdate": true},
blocked: false,
},
{
name: "已签到、无 isAllowUpdate 字段 → 不拦截(保守)",
rows: map[string]interface{}{"signStatus": float64(1)},
blocked: false,
},
{
name: "已签到、有次数限制但还剩 → 不拦截",
rows: map[string]interface{}{"signStatus": "1", "isAllowUpdate": true, "updateLimit": true, "leftNum": 2},
blocked: false,
},
{
name: "已签到、次数用尽 → 拦截",
rows: map[string]interface{}{"signStatus": "1", "isAllowUpdate": true, "updateLimit": true, "leftNum": 0},
blocked: true,
},
{
name: "请假(signStatus=5) → 不拦截",
rows: map[string]interface{}{"signStatus": "5"},
blocked: false,
},
{
name: "nil rows → 不拦截",
rows: nil,
blocked: false,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
msg, blocked := SignUpdateBlocked(c.rows)
if blocked != c.blocked {
t.Fatalf("blocked=%v want %v (msg=%q)", blocked, c.blocked, msg)
}
if blocked && msg == "" {
t.Fatal("拦截时应给出提示文案")
}
})
}
}
// EvalTaskPhase 复现 H5 的 Gt():>end → finished,<=begin → noStart,否则 running。
func TestEvalTaskPhase(t *testing.T) {
w := ParseSignWindow(realRows())
if got := EvalTaskPhase(w, wantTime(2026, 10, 19, 11, 0)); got != TaskPhaseNoStart {
t.Fatalf("应为 noStart,实际 %q", got)
}
if got := EvalTaskPhase(w, wantTime(2026, 10, 25, 20, 0)); got != TaskPhaseRunning {
t.Fatalf("应为 running,实际 %q", got)
}
if got := EvalTaskPhase(w, wantTime(2026, 10, 26, 1, 0)); got != TaskPhaseFinished {
t.Fatalf("应为 finished,实际 %q", got)
}
// 边界:等于开始时 → noStart(H5 用 isSameOrBefore)
if got := EvalTaskPhase(w, wantTime(2026, 10, 25, 18, 30)); got != TaskPhaseNoStart {
t.Fatalf("等于开始时刻应为 noStart(H5 isSameOrBefore),实际 %q", got)
}
}