package tasks import ( "strings" "testing" "time" "github.com/kami/maven/internal/say" ) func at(y int, m time.Month, d int) *time.Time { t := time.Date(y, m, d, 0, 0, 0, 0, time.UTC) return &t } func now() time.Time { return time.Date(2026, 8, 1, 14, 0, 0, 0, time.UTC) } func texts(rs []Ranked) []string { out := make([]string, len(rs)) for i, r := range rs { out[i] = r.Text } return out } func TestRankOrdersByDeadline(t *testing.T) { items := []Item{ {ID: 1, Text: "через неделю", Status: StatusOpen, Due: at(2026, 8, 7), Created: now()}, {ID: 2, Text: "просрочено", Status: StatusOpen, Due: at(2026, 7, 28), Created: now()}, {ID: 3, Text: "без срока", Status: StatusOpen, Created: now()}, {ID: 4, Text: "сегодня", Status: StatusOpen, Due: at(2026, 8, 1), Created: now()}, {ID: 5, Text: "завтра", Status: StatusOpen, Due: at(2026, 8, 2), Created: now()}, } got := texts(Rank(items, now())) want := []string{"просрочено", "сегодня", "завтра", "через неделю", "без срока"} for i := range want { if got[i] != want[i] { t.Fatalf("order = %v, want %v", got, want) } } } func TestRankCandidatesNeverOutrankOpenWork(t *testing.T) { items := []Item{ {ID: 1, Text: "его задача", Status: StatusOpen, Created: now()}, // Everything about this one screams urgent — and it is still a guess. {ID: 2, Text: "из письма", Status: StatusCandidate, Due: at(2026, 7, 1), Weight: 3, Created: now()}, } got := Rank(items, now()) if got[0].Text != "его задача" { t.Errorf("order = %v, want his own work first", texts(got)) } } func TestRankWeightLiftsUndatedWork(t *testing.T) { items := []Item{ {ID: 1, Text: "обычная", Status: StatusOpen, Created: now()}, {ID: 2, Text: "важная", Status: StatusOpen, Weight: 2, Created: now()}, } got := Rank(items, now()) if got[0].Text != "важная" { t.Errorf("order = %v, want the weighted task first", texts(got)) } if got[0].Reason != "важно" { t.Errorf("reason = %q, want важно", got[0].Reason) } // A deadline still beats a weight: a date is a fact, a weight is a feeling. items = append(items, Item{ID: 3, Text: "сегодня", Status: StatusOpen, Due: at(2026, 8, 1), Created: now()}) got = Rank(items, now()) if got[0].Text != "сегодня" { t.Errorf("order = %v, want the dated task first", texts(got)) } } func TestRankOldestFirstOnATie(t *testing.T) { old := now().AddDate(0, 0, -3) items := []Item{ {ID: 1, Text: "новая", Status: StatusOpen, Created: now()}, {ID: 2, Text: "старая", Status: StatusOpen, Created: old}, } got := Rank(items, now()) if got[0].Text != "старая" { t.Errorf("order = %v, want FIFO on equal urgency", texts(got)) } } func TestRankNoInventedReason(t *testing.T) { got := Rank([]Item{{ID: 1, Text: "что-то", Status: StatusOpen, Created: now()}}, now()) if got[0].Reason != "" { t.Errorf("reason = %q — nothing distinguished this task, so there is nothing to say", got[0].Reason) } if got[0].Score != 0 { t.Errorf("score = %v, want 0", got[0].Score) } } func TestRankAgeIsCappedAndNamed(t *testing.T) { items := []Item{ {ID: 1, Text: "прошлогодняя", Status: StatusOpen, Created: now().AddDate(-1, 0, 0)}, {ID: 2, Text: "трёхнедельная", Status: StatusOpen, Created: now().AddDate(0, 0, -21)}, } got := Rank(items, now()) if got[0].Score != scoreAgeCap { t.Errorf("oldest score = %v, want the cap %v", got[0].Score, float64(scoreAgeCap)) } if got[0].Reason != "давно в списке" { t.Errorf("reason = %q", got[0].Reason) } } // A task due at 23:00 today is due today, not overdue since this morning. func TestRankDueTodayIsNotOverdue(t *testing.T) { due := time.Date(2026, 8, 1, 23, 0, 0, 0, time.UTC) got := Rank([]Item{{ID: 1, Text: "вечером", Status: StatusOpen, Due: &due, Created: now()}}, now()) if got[0].Reason != "сегодня" { t.Errorf("reason = %q, want сегодня", got[0].Reason) } } func TestRankOverdueDaysAreCounted(t *testing.T) { got := Rank([]Item{ {ID: 1, Text: "вчера", Status: StatusOpen, Due: at(2026, 7, 31), Created: now()}, {ID: 2, Text: "давно", Status: StatusOpen, Due: at(2026, 7, 20), Created: now()}, }, now()) if got[0].Text != "давно" { t.Errorf("order = %v, want the later-overdue task first", texts(got)) } if got[0].Reason != "просрочено на 12 дней" { t.Errorf("reason = %q", got[0].Reason) } // One day goes through the same entry as twelve: «на 1 день» is what the // count helper says, so there is no reason_overdue_day any more. if got[1].Reason != "просрочено на 1 день" { t.Errorf("reason = %q", got[1].Reason) } } func TestFormatRUNamesReasonsAndSeparatesCandidates(t *testing.T) { ranked := Rank([]Item{ {ID: 1, Text: "оплатить интернет", Status: StatusOpen, Due: at(2026, 8, 1), Created: now()}, {ID: 2, Text: "купить молоко", Status: StatusOpen, Created: now()}, {ID: 3, Text: "продлить страховку", Status: StatusCandidate, Due: at(2026, 7, 1), Created: now()}, }, now()) got := FormatRU(ranked) if !strings.HasPrefix(got, "сначала: оплатить интернет (сегодня)") { t.Errorf("reply = %q", got) } if !strings.Contains(got, "не подтверждал: продлить страховку") { t.Errorf("candidate not named as unconfirmed: %q", got) } // A candidate's due date is Maven's reading of a mail, not his statement. if strings.Contains(got, "продлить страховку (") { t.Errorf("a candidate must be listed without a reason: %q", got) } // Persona: nothing masculine, no pet names, informal address only. for _, bad := range []string{"рад ", "понял ", "милый", "дорогой", "вам", "ваши"} { if strings.Contains(got, bad) { t.Errorf("reply %q contains %q", got, bad) } } } func TestFormatRUCapsTheSpokenList(t *testing.T) { var items []Item for i := 0; i < SpokenLimit+3; i++ { items = append(items, Item{ID: int64(i), Text: "задача", Status: StatusOpen, Created: now()}) } got := FormatRU(Rank(items, now())) if !strings.Contains(got, "и ещё 3") { t.Errorf("reply = %q, want the tail summarised", got) } if strings.Count(got, "задача") != SpokenLimit { t.Errorf("reply = %q, want exactly %d named", got, SpokenLimit) } } func TestFormatRUEmpty(t *testing.T) { if got := FormatRU(nil); got != "задач нет." { t.Errorf("reply = %q", got) } } // A due date read back from the store is a UTC instant, so comparing calendar // days in ITS location put every date a day out east of Greenwich: the row said // "сегодня" for a task due tomorrow, and "просрочено на 1 день" on the due date // itself while the due column one cell over said otherwise. func TestRankComparesDaysInTheCallersLocation(t *testing.T) { tz := time.FixedZone("UTC+4", 4*3600) // Entered on the web form as 2026-08-02 local, stored and read back as UTC. due := time.Date(2026, 8, 2, 0, 0, 0, 0, tz).UTC() local := time.Date(2026, 8, 1, 10, 0, 0, 0, tz) got := Rank([]Item{{ID: 1, Text: "оплатить интернет", Status: StatusOpen, Due: &due, Created: local}}, local) if got[0].Reason != "завтра" { t.Errorf("reason = %q, want завтра on the day before", got[0].Reason) } // The morning of the due date itself. onTheDay := time.Date(2026, 8, 2, 10, 0, 0, 0, tz) got = Rank([]Item{{ID: 1, Text: "оплатить интернет", Status: StatusOpen, Due: &due, Created: local}}, onTheDay) if got[0].Reason != "сегодня" { t.Errorf("reason = %q, want сегодня on the due date", got[0].Reason) } if got[0].Score != scoreDueToday { t.Errorf("score = %v, want %v", got[0].Score, float64(scoreDueToday)) } } // "срочно" and "важно" are two rungs and the read-back said "важно" for both, // which reports a word he did not say. func TestRankNamesTheUrgencyHeStated(t *testing.T) { got := Rank([]Item{ {ID: 1, Text: "оплатить интернет", Status: StatusOpen, Weight: 3, Created: now()}, {ID: 2, Text: "починить кран", Status: StatusOpen, Weight: 2, Created: now()}, }, now()) if got[0].Reason != "срочно" { t.Errorf("reason = %q, want срочно", got[0].Reason) } if got[1].Reason != "важно" { t.Errorf("reason = %q, want важно", got[1].Reason) } } // The package doc guarantees a class ordering. Age used to invert it: an // undated task at the age cap outscored a dated one three weeks out. func TestRankDatedWorkBeatsAgeAlone(t *testing.T) { got := Rank([]Item{ {ID: 1, Text: "старьё", Status: StatusOpen, Created: now().AddDate(0, 0, -70)}, {ID: 2, Text: "через три недели", Status: StatusOpen, Due: at(2026, 8, 22), Created: now()}, }, now()) if got[0].Text != "через три недели" { t.Errorf("order = %v, want the dated task first", texts(got)) } } // A bare "и ещё 5" trails off when spoken. func TestFormatRUTailCarriesTheNoun(t *testing.T) { var items []Item for i := 0; i < SpokenLimit+3; i++ { items = append(items, Item{ID: int64(i), Text: "дело", Status: StatusOpen, Created: now()}) } if got := FormatRU(Rank(items, now())); !strings.Contains(got, "и ещё 3 задачи") { t.Errorf("reply = %q, want the count with its noun", got) } for n, want := range map[int]string{1: "задача", 2: "задачи", 5: "задач", 11: "задач", 21: "задача"} { if got := say.CountWord(n, "задача", "задачи", "задач"); got != want { t.Errorf("CountWord(%d) = %q, want %q", n, got, want) } } }