230 lines
8.1 KiB
Go
230 lines
8.1 KiB
Go
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)
|
||
}
|
||
}
|