6fba4d6931
The PR 113 review found four defects in one line file. Swept the other four families and the Go side for the same four. The JSON was clean: no undeclared placeholder, no abbreviation spoken, no single-variant entry left unfixed. One register leak — page_blocked read "robots.txt" out loud, which is a filename, not a reason he can act on. The count rule was not clean. Four more copies of the three-way agreement existed and two of them were wrong: ruPlural produced «1 минут назад» and «5 часа назад» because formatTime spelled the noun out. pluralTasksRU was a fifth copy. All of them now call say.CountWord. The pending-notification summary picks the whole phrase, because the adjective declines with the noun.
250 lines
9.6 KiB
Go
250 lines
9.6 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|