Projeto bot pontos

This commit is contained in:
2026-10-23 22:31:02 -03:00
parent 29614855ff
commit b35e1a7d69
116 changed files with 14523 additions and 0 deletions

View File

@@ -0,0 +1,229 @@
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)
}
}