Files
claude 6fba4d6931 say: one count rule everywhere, and a page she can explain (V-521)
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.
2026-08-04 16:47:23 +04:00

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)
}
}
}