package rules import ( "testing" "time" "botpontos/internal/domain" ) var saoPaulo = domain.LoadLocation("America/Sao_Paulo") const testDay = "2026-10-14" // quarta-feira func at(clock string) time.Time { return domain.MustParseClock(clock).On(testDay, saoPaulo) } func workday(clocks ...string) domain.Workday { points := make([]domain.Point, 0, len(clocks)) for _, c := range clocks { points = append(points, domain.Point{OccurredAt: at(c)}) } return CurrentWorkday(points, testDay, saoPaulo) } func TestCurrentWorkdayFiltersAndSorts(t *testing.T) { yesterday := domain.MustParseClock("17:00").On("2026-10-13", saoPaulo) points := []domain.Point{{OccurredAt: at("12:00")}, {OccurredAt: yesterday}, {OccurredAt: at("08:00")}} w := CurrentWorkday(points, testDay, saoPaulo) if w.Count() != 2 || !w.Entry1().Equal(at("08:00")) || !w.Exit1().Equal(at("12:00")) { t.Fatalf("pontos inesperados: %+v", w.Points) } if w.Invalid || w.Overtime { t.Fatalf("dia deveria ser válido: %+v", w) } } func TestCurrentWorkdayInvalidAndOvertime(t *testing.T) { if w := workday("08:00", "08:00"); !w.Invalid { t.Error("pontos repetidos deveriam invalidar o dia") } if w := workday("08:00", "12:00", "13:00", "17:00", "18:00"); !w.Overtime { t.Error("mais de quatro pontos deveria ser hora extra") } } func TestEntrada2AndSaida2(t *testing.T) { w := workday("08:00", "12:00", "13:00") entrada2, err := Entrada2Time(w, 60) if err != nil || !entrada2.Equal(at("13:00")) { t.Fatalf("entrada2 = %v, %v", entrada2, err) } saida2, err := Saida2Time(w, 480) if err != nil || !saida2.Equal(at("17:00")) { t.Fatalf("saida2 = %v, %v", saida2, err) } if reminder := ReminderAt(saida2, 5); !reminder.Equal(at("16:55")) { t.Fatalf("lembrete = %v", reminder) } if _, err := Saida2Time(workday("08:00", "17:00", "18:00"), 480); err != ErrNoRemainingWork { t.Fatalf("esperado ErrNoRemainingWork, veio %v", err) } if _, err := Entrada2Time(workday("08:00"), 60); err != ErrNotEnoughPoints { t.Fatalf("esperado ErrNotEnoughPoints, veio %v", err) } } func TestFormat(t *testing.T) { if got := FormatMinutes(480); got != "8h00" { t.Errorf("FormatMinutes(480) = %s", got) } if got := FormatMinutes(390); got != "6h30" { t.Errorf("FormatMinutes(390) = %s", got) } if got := FormatMinutesShort(15); got != "15 min" { t.Errorf("FormatMinutesShort(15) = %s", got) } } func TestValidateSchedule(t *testing.T) { global := domain.DefaultQueryWindow() ok := domain.DefaultSchedule(1) if errs := ValidateSchedule(ok, global, 15*time.Minute); len(errs) != 0 { t.Fatalf("jornada padrão deveria ser válida: %v", errs) } short := ok short.LunchMinutes = 15 // 15 − 5 = 10 < 15 if errs := ValidateSchedule(short, global, 15*time.Minute); len(errs) == 0 { t.Fatal("almoço de 15 min com antecedência de 5 deveria ser recusado") } twenty := ok twenty.LunchMinutes = 20 // 20 − 5 = 15 if errs := ValidateSchedule(twenty, global, 15*time.Minute); len(errs) != 0 { t.Fatalf("almoço de 20 min deveria passar: %v", errs) } } func TestValidateWindow(t *testing.T) { if errs := ValidateWindow(domain.DefaultQueryWindow(), 15*time.Minute, ""); len(errs) != 0 { t.Fatalf("janela padrão deveria ser válida: %v", errs) } misaligned := domain.DefaultQueryWindow() misaligned.Start = domain.MustParseClock("11:20") if errs := ValidateWindow(misaligned, 15*time.Minute, ""); len(errs) == 0 { t.Error("horário fora do alinhamento deveria ser recusado") } outOfOrder := domain.DefaultQueryWindow() outOfOrder.DayCheckDeadline = domain.MustParseClock("16:00") if errs := ValidateWindow(outOfOrder, 15*time.Minute, ""); len(errs) == 0 { t.Error("confirmação depois do fim das reverificações deveria ser recusada") } tooShort := domain.QueryWindow{ Start: domain.MustParseClock("11:30"), DayCheckDeadline: domain.MustParseClock("11:45"), NoPointsRecheckUntil: domain.MustParseClock("13:00"), End: domain.MustParseClock("15:00"), } if errs := ValidateWindow(tooShort, 15*time.Minute, ""); len(errs) == 0 { t.Error("janela com menos de 4 horas deveria ser recusada") } } func plan(tick string, w domain.Workday, saida2At *time.Time) Plan { return PlanNextCheck(PlanInput{ Tick: at(tick), Day: testDay, Location: saoPaulo, Window: domain.DefaultQueryWindow(), Workday: w, Saida2At: saida2At, Reconciliation: 15 * time.Minute, Slow: 60 * time.Minute, ExitConfirmFrom: 20 * time.Minute, ExitConfirmTo: 5 * time.Minute, }) } func assertPlan(t *testing.T, got Plan, status domain.DayStatus, next string) { t.Helper() if got.Status != status { t.Errorf("status = %s, esperado %s", got.Status, status) } switch { case next == "" && got.NextCheckAt != nil: t.Errorf("próxima consulta = %s, esperado nenhuma", got.NextCheckAt.In(saoPaulo).Format("15:04")) case next != "" && got.NextCheckAt == nil: t.Errorf("próxima consulta = nenhuma, esperado %s", next) case next != "" && !got.NextCheckAt.Equal(at(next)): t.Errorf("próxima consulta = %s, esperado %s", got.NextCheckAt.In(saoPaulo).Format("15:04"), next) } } func TestPlanNoPoints(t *testing.T) { empty := workday() assertPlan(t, plan("11:30", empty, nil), domain.DayAwaiting, "11:45") assertPlan(t, plan("11:45", empty, nil), domain.DayNoPoints, "12:45") assertPlan(t, plan("13:45", empty, nil), domain.DayNoPoints, "14:45") assertPlan(t, plan("14:45", empty, nil), domain.DayNotWorking, "") } func TestPlanResumedWithoutPoints(t *testing.T) { in := PlanInput{ Tick: at("12:30"), Day: testDay, Location: saoPaulo, Window: domain.DefaultQueryWindow(), Workday: workday(), Resumed: true, Reconciliation: 15 * time.Minute, Slow: time.Hour, ExitConfirmFrom: 20 * time.Minute, ExitConfirmTo: 5 * time.Minute, } assertPlan(t, PlanNextCheck(in), domain.DayWorking, "12:45") } func TestPlanBeforeAndDuringLunch(t *testing.T) { assertPlan(t, plan("11:30", workday("08:00"), nil), domain.DayWorking, "11:45") assertPlan(t, plan("12:15", workday("08:00", "12:00"), nil), domain.DayWorking, "12:30") } func TestPlanAfternoonWithConfirmation(t *testing.T) { afternoon := workday("08:00", "12:00", "13:00") reminder := at("16:55") assertPlan(t, plan("13:00", afternoon, &reminder), domain.DayWorking, "14:00") assertPlan(t, plan("16:00", afternoon, &reminder), domain.DayWorking, "16:45") assertPlan(t, plan("16:45", afternoon, &reminder), domain.DayWorking, "") } func TestPlanFinishedAndWindowEnd(t *testing.T) { assertPlan(t, plan("17:00", workday("08:00", "12:00", "13:00", "17:00"), nil), domain.DayFinished, "") // 3 pontos às 18:30 sem lembrete agendado: a próxima consulta lenta (19:30) passa do fim da janela. assertPlan(t, plan("18:30", workday("10:00", "14:00", "15:00"), nil), domain.DayWorking, "") } func TestConfirmTick(t *testing.T) { cases := map[string]string{"16:55": "16:45", "17:00": "16:45", "16:50": "16:30"} for reminder, expected := range cases { got := ConfirmTick(at(reminder), 15*time.Minute, 5*time.Minute) if !got.Equal(at(expected)) { t.Errorf("ConfirmTick(%s) = %s, esperado %s", reminder, got.In(saoPaulo).Format("15:04"), expected) } } } func TestResumeNextCheck(t *testing.T) { w := domain.DefaultQueryWindow() if next := ResumeNextCheck(at("09:10"), w, testDay, saoPaulo, 15*time.Minute); next == nil || !next.Equal(at("11:30")) { t.Errorf("antes da janela deveria começar às 11:30, veio %v", next) } if next := ResumeNextCheck(at("12:31"), w, testDay, saoPaulo, 15*time.Minute); next == nil || !next.Equal(at("12:45")) { t.Errorf("dentro da janela deveria ser o próximo tick, veio %v", next) } if next := ResumeNextCheck(at("19:10"), w, testDay, saoPaulo, 15*time.Minute); next != nil { t.Errorf("depois da janela não deveria haver consulta, veio %v", next) } } func TestParseClock(t *testing.T) { for _, invalid := range []string{"7:30", "24:00", "12:60", "ab:cd", "12-30", ""} { if _, err := domain.ParseClock(invalid); err == nil { t.Errorf("ParseClock(%q) deveria falhar", invalid) } } if c, err := domain.ParseClock("11:45"); err != nil || c.String() != "11:45" { t.Errorf("ParseClock(11:45) = %v, %v", c, err) } }