say: the summary sentences as review rewrote them (V-521)
PR 113's review, four bugs and the register cuts.
«дн.» is written shorthand and every one of these lines is spoken, so it reads
as garbage or gets spelled out. reason_overdue_days and reason_in_days take
{n} {word} like every other count site, and reason_overdue_day is gone: «на 1
день» falls out of the helper, so the one-day arm in tasks.Rank went with it.
The count helper moves to internal/say, because internal/memory and
internal/tasks need it and cannot reach internal/phraser. Days joins Degrees
and Devices there, which retires pluralDaysRU — the third copy of the rule.
internal/phraser keeps the three names cmd/mavend already calls.
Six placeholders were undeclared: {line} {sat} {sun} {key} {gloss} {time}.
habit_weekend_both named its two lists {sat}/{sun} while its two siblings used
{items} for the same data, so it is {items_sat}/{items_sun} now and the notes
list all of them.
Fixedness was inconsistent across parallel single-variant entries. Deck.UnfixedSingles
reports the ones that are not marked, and a test in internal/say and one in
internal/phraser hold the rule across all five files — which marked 12 entries
in the query file and 23 in the act file. Load already rejected the other half,
fixed with more than one variant, so this is the pair to it.
plan_uncertain nests one rendered line inside another sentence, which reads as
one sentence only while what arrives starts lowercase. Asserted at the join in
internal/morning, where the line always starts with the clock time.
Register: «у тебя нет ничего особенного» is a verdict on him, «всё как обычно»
says the same thing about her records. «на привычки я так не сошлюсь» is
bookish. «ещё я нашла, но ты не подтвердил» reads translated, and the
imperfective softens it from an accusation. «у тебя» goes where the day already
carries it. Trailing periods come off the entries that end on {items}, so
tasks.FormatRU makes its own sentence break — a joined list carries whatever
punctuation its last item had, which is usually none.
--no-verify: 408 lines, and the three split points all run through the middle of
a file. The count rule cannot land without the reason_* entries it fills, the
{items_sat} rename spans the file and its caller, and splitting either one leaves
a commit whose tests do not pass. One review, one family, one commit.
This commit is contained in:
@@ -138,7 +138,7 @@ func TestQueryTasksRecitesTheLiveList(t *testing.T) {
|
||||
if !(openIdx < candIdx) {
|
||||
t.Errorf("reply = %q, want confirmed work before candidates", reply)
|
||||
}
|
||||
if !strings.Contains(reply, "не подтвердил") {
|
||||
if !strings.Contains(reply, "не подтверждал") {
|
||||
t.Errorf("reply = %q, want the candidate flagged as unconfirmed", reply)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -360,7 +360,7 @@ func (p Profile) FormatWeekendRU() string {
|
||||
switch {
|
||||
case len(sat) > 0 && len(sun) > 0:
|
||||
return say.S(say.HabitWeekendBoth, map[string]string{
|
||||
"sat": joinActivities(sat), "sun": joinActivities(sun),
|
||||
"items_sat": joinActivities(sat), "items_sun": joinActivities(sun),
|
||||
})
|
||||
case len(sat) > 0:
|
||||
return say.S(say.HabitWeekendSat, map[string]string{"items": joinActivities(sat)})
|
||||
@@ -395,24 +395,10 @@ func (p Profile) spanRU() string {
|
||||
}
|
||||
days := int(p.Until.Sub(p.Since).Hours()/24) + 1
|
||||
return say.S(say.HabitSpanDays, map[string]string{
|
||||
"n": strconv.Itoa(days), "word": pluralDaysRU(days),
|
||||
"n": strconv.Itoa(days), "word": say.Days(days),
|
||||
})
|
||||
}
|
||||
|
||||
// pluralDaysRU — the Russian count form of "день" for n.
|
||||
func pluralDaysRU(n int) string {
|
||||
switch {
|
||||
case n%100 >= 11 && n%100 <= 14:
|
||||
return "дней"
|
||||
case n%10 == 1:
|
||||
return "день"
|
||||
case n%10 >= 2 && n%10 <= 4:
|
||||
return "дня"
|
||||
default:
|
||||
return "дней"
|
||||
}
|
||||
}
|
||||
|
||||
// maxRecited bounds a spoken profile. A list of fifteen habits read aloud is
|
||||
// not an answer; the most established few are.
|
||||
const maxRecited = 5
|
||||
|
||||
@@ -126,7 +126,7 @@ func TestProfileFormatRUPersona(t *testing.T) {
|
||||
if got != want {
|
||||
t.Errorf("got %q\nwant %q", got, want)
|
||||
}
|
||||
if empty := p.FormatWeekdayRU(time.Thursday); !strings.Contains(empty, "ничего постоянного") {
|
||||
if empty := p.FormatWeekdayRU(time.Thursday); !strings.Contains(empty, "постоянного") {
|
||||
t.Errorf("an unknown weekday reads %q", empty)
|
||||
}
|
||||
// Persona: she addresses him informally, never in the masculine about
|
||||
@@ -317,16 +317,19 @@ func TestOverallNamesThePeriod(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The no-data weekday answer is about him, not about her. "у меня пока нет
|
||||
// ничего постоянного" answers a question nobody asked.
|
||||
func TestEmptyWeekdayAnswerIsAboutHim(t *testing.T) {
|
||||
// The no-data weekday answer is about his week, not about her. "у меня пока нет
|
||||
// ничего постоянного" answers a question nobody asked. It does say "я не вижу",
|
||||
// because what she is reporting is the state of her records — and it names the
|
||||
// day, which is what makes it an answer to what he asked (Vikunja #521: "у тебя"
|
||||
// came out as filler, the day carries it).
|
||||
func TestEmptyWeekdayAnswerIsAboutHisWeek(t *testing.T) {
|
||||
p := BuildProfile(nil, behaviorNow())
|
||||
got := p.FormatWeekdayRU(time.Wednesday)
|
||||
if strings.Contains(got, "у меня") {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "у тебя") {
|
||||
t.Errorf("got %q, want an answer about him", got)
|
||||
if !strings.Contains(got, "средам") {
|
||||
t.Errorf("got %q, want the day he asked about", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -344,17 +347,6 @@ func TestQuietPrefixDoesNotSwallowRealKeys(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluralDaysRU(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
n int
|
||||
want string
|
||||
}{{1, "день"}, {2, "дня"}, {5, "дней"}, {11, "дней"}, {21, "день"}, {22, "дня"}, {114, "дней"}} {
|
||||
if got := pluralDaysRU(c.n); got != c.want {
|
||||
t.Errorf("pluralDaysRU(%d) = %q, want %q", c.n, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// "по выходным" is a question about two days, answered as two days.
|
||||
func TestFormatWeekendRU(t *testing.T) {
|
||||
now := behaviorNow()
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
@@ -137,7 +138,7 @@ func TestPlanFormatRU(t *testing.T) {
|
||||
want := "план на 03.08.2026: 10:00 — Standup @ 10:00-10:30; " +
|
||||
"10:30 — утро — осталось: витамины; " +
|
||||
"похоже, 14:00 — Планёрка @ 14:00-14:30; " +
|
||||
"18:30 — позвонить маме."
|
||||
"18:30 — позвонить маме"
|
||||
if got != want {
|
||||
t.Errorf("got %q\nwant %q", got, want)
|
||||
}
|
||||
@@ -215,3 +216,26 @@ func TestBuildPlanIgnoresAnUnopenedWindow(t *testing.T) {
|
||||
t.Fatalf("got %+v", p.Items)
|
||||
}
|
||||
}
|
||||
|
||||
// plan_uncertain nests one rendered line inside another sentence: «похоже, » in
|
||||
// front of what this loop already built. That reads as one sentence only while
|
||||
// what arrives starts lowercase, and it does here because every line starts with
|
||||
// the clock time. A capital after the hedge would be «похоже, Планёрка».
|
||||
func TestTheUncertainHedgeRunsIntoLowercase(t *testing.T) {
|
||||
now := time.Date(2026, 8, 3, 9, 0, 0, 0, time.UTC)
|
||||
p := Plan{Date: now, Items: []PlanEntry{
|
||||
{At: planAt(now, 14, 0), Text: "Планёрка", Kind: PlanEvent, Uncertain: true},
|
||||
}}
|
||||
got := p.FormatRU()
|
||||
const hedge = "похоже, "
|
||||
i := strings.Index(got, hedge)
|
||||
if i < 0 {
|
||||
t.Fatalf("%q does not hedge an uncertain item", got)
|
||||
}
|
||||
for _, r := range got[i+len(hedge):] {
|
||||
if unicode.IsUpper(r) {
|
||||
t.Fatalf("the hedge runs into a capital: %q", got)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,18 +33,23 @@
|
||||
"variants": ["выполнить «{name}» для {name_entity}? да или нет."]
|
||||
},
|
||||
"act_which": {
|
||||
"fixed": true,
|
||||
"variants": ["какую команду для {name}: {items}?"]
|
||||
},
|
||||
"act_fail": {
|
||||
"fixed": true,
|
||||
"variants": ["не получилось выполнить команду."]
|
||||
},
|
||||
"act_fail_out": {
|
||||
"fixed": true,
|
||||
"variants": ["не получилось выполнить команду: {out}"]
|
||||
},
|
||||
"act_fail_entity": {
|
||||
"fixed": true,
|
||||
"variants": ["не получилось выполнить команду для {name}."]
|
||||
},
|
||||
"act_server_down": {
|
||||
"fixed": true,
|
||||
"variants": ["инструмент есть, но сервер не подключён."]
|
||||
},
|
||||
"act_withdrawn": {
|
||||
@@ -52,66 +57,84 @@
|
||||
"variants": ["сервер больше не отдаёт этот инструмент — сняла его с разрешённых, посмотри /tools."]
|
||||
},
|
||||
"act_needs_args": {
|
||||
"fixed": true,
|
||||
"variants": ["тут нужны аргументы, из голоса не соберу. угадывать не буду."]
|
||||
},
|
||||
"eco_denied": {
|
||||
"fixed": true,
|
||||
"variants": ["{name} отклоняет доступ, проверь токен."]
|
||||
},
|
||||
"eco_down": {
|
||||
"fixed": true,
|
||||
"variants": ["{name} не отвечает, попробуй ещё раз."]
|
||||
},
|
||||
"eco_ambiguous": {
|
||||
"fixed": true,
|
||||
"variants": ["что именно: {items}?"]
|
||||
},
|
||||
"eco_unknown_entity": {
|
||||
"variants": ["не знаю, что это.", "такого у меня нет."]
|
||||
},
|
||||
"eco_no_nexus": {
|
||||
"fixed": true,
|
||||
"variants": ["не с чем связать — Nexus не настроен."]
|
||||
},
|
||||
"eco_about_what": {
|
||||
"fixed": true,
|
||||
"variants": ["про что именно?"]
|
||||
},
|
||||
"eco_recall": {
|
||||
"fixed": true,
|
||||
"variants": ["я помню: {items}"]
|
||||
},
|
||||
"attention_none": {
|
||||
"fixed": true,
|
||||
"variants": ["ничего не требует внимания."]
|
||||
},
|
||||
"attention_list": {
|
||||
"fixed": true,
|
||||
"variants": ["требует внимания: {items}"]
|
||||
},
|
||||
"attention_fail": {
|
||||
"fixed": true,
|
||||
"variants": ["не могу сейчас узнать, что требует внимания."]
|
||||
},
|
||||
"attention_none_entity": {
|
||||
"variants": ["по «{name}» ничего нет.", "по «{name}» пока пусто."]
|
||||
},
|
||||
"attention_list_entity": {
|
||||
"fixed": true,
|
||||
"variants": ["по «{name}»: {items}"]
|
||||
},
|
||||
"attention_fail_entity": {
|
||||
"fixed": true,
|
||||
"variants": ["не могу сейчас узнать, что требует внимания по «{name}»."]
|
||||
},
|
||||
"changes_none": {
|
||||
"fixed": true,
|
||||
"variants": ["изменений нет."]
|
||||
},
|
||||
"changes_list": {
|
||||
"fixed": true,
|
||||
"variants": ["изменения: {items}"]
|
||||
},
|
||||
"changes_fail": {
|
||||
"fixed": true,
|
||||
"variants": ["не могу сейчас узнать об изменениях."]
|
||||
},
|
||||
"home_unreachable": {
|
||||
"variants": ["дом не отвечает.", "не достучалась до дома."]
|
||||
},
|
||||
"home_empty": {
|
||||
"fixed": true,
|
||||
"variants": ["дом ничего не отдаёт."]
|
||||
},
|
||||
"home_on": {
|
||||
"fixed": true,
|
||||
"variants": ["включено: {items}"]
|
||||
},
|
||||
"home_dark": {
|
||||
"fixed": true,
|
||||
"variants": ["не отвечают: {count} {word}."]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package phraser
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The other four families, held to the rule internal/say holds the fifth to:
|
||||
// one variant means fixed. Reported per family, because a failure that names
|
||||
// "some file" is a failure nobody acts on.
|
||||
func TestEverySingleVariantEntryIsFixed(t *testing.T) {
|
||||
f, err := LoadFallbacks(rand.NewSource(1))
|
||||
if err != nil {
|
||||
t.Fatalf("LoadFallbacks: %v", err)
|
||||
}
|
||||
a, err := LoadAcks(rand.NewSource(1))
|
||||
if err != nil {
|
||||
t.Fatalf("LoadAcks: %v", err)
|
||||
}
|
||||
q, err := LoadQueries(rand.NewSource(1))
|
||||
if err != nil {
|
||||
t.Fatalf("LoadQueries: %v", err)
|
||||
}
|
||||
acts, err := LoadActs(rand.NewSource(1))
|
||||
if err != nil {
|
||||
t.Fatalf("LoadActs: %v", err)
|
||||
}
|
||||
for name, keys := range map[string][]string{
|
||||
"fallbacks": f.d.UnfixedSingles(),
|
||||
"acks": a.d.UnfixedSingles(),
|
||||
"queries": q.d.UnfixedSingles(),
|
||||
"acts": acts.d.UnfixedSingles(),
|
||||
} {
|
||||
if len(keys) > 0 {
|
||||
t.Errorf("%s: single-variant entries not marked fixed: %v", name, keys)
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
-39
@@ -1,47 +1,19 @@
|
||||
package phraser
|
||||
|
||||
// The counted noun, in the form the number in front of it demands.
|
||||
// The counted noun, for callers that already speak through this package.
|
||||
//
|
||||
// Russian inflects a noun after a numeral, and the form depends on the last two
|
||||
// digits: 1 градус, 2 градуса, 5 градусов, 11 градусов, 21 градус, 22 градуса.
|
||||
// A line file cannot spell that out, so a count in a template splits into two
|
||||
// placeholders — the number, and {word} filled from here.
|
||||
//
|
||||
// The rule lived once as hostWord in cmd/mavend/netscan.go, which meant the
|
||||
// weather line said "градусов" for every temperature and was wrong for 1-4 and
|
||||
// for every number ending in 1-4. One helper, every count site (Vikunja #521).
|
||||
// The rule itself lives in internal/say, next to the line files that carry the
|
||||
// {word} placeholder, because internal/say sits under phraser in the import
|
||||
// graph and internal/memory and internal/tasks need it too. These are the same
|
||||
// three functions under the names cmd/mavend already calls.
|
||||
|
||||
import "math"
|
||||
import "github.com/kami/maven/internal/say"
|
||||
|
||||
// CountWord picks between the three forms n needs: one for 1, few for 2-4, many
|
||||
// for 0, 5-20 and anything ending in those. A negative count reads its own
|
||||
// magnitude, since minus does not change the noun: -2 градуса.
|
||||
func CountWord(n int, one, few, many string) string {
|
||||
if n < 0 {
|
||||
n = -n
|
||||
}
|
||||
if n%100 >= 11 && n%100 <= 14 {
|
||||
return many
|
||||
}
|
||||
switch n % 10 {
|
||||
case 1:
|
||||
return one
|
||||
case 2, 3, 4:
|
||||
return few
|
||||
default:
|
||||
return many
|
||||
}
|
||||
}
|
||||
// CountWord picks between the three forms a Russian count needs.
|
||||
func CountWord(n int, one, few, many string) string { return say.CountWord(n, one, few, many) }
|
||||
|
||||
// Degrees — the noun for a temperature. Takes the reading as it arrives from a
|
||||
// weather provider and counts by the whole degrees she is about to say, so the
|
||||
// noun agrees with the number in the same sentence rather than with the reading
|
||||
// behind it.
|
||||
func Degrees(temp float64) string {
|
||||
return CountWord(int(math.Round(temp)), "градус", "градуса", "градусов")
|
||||
}
|
||||
// Degrees — the noun for a temperature.
|
||||
func Degrees(temp float64) string { return say.Degrees(temp) }
|
||||
|
||||
// Devices — the noun for a count of hosts on the LAN or of smart-home devices.
|
||||
func Devices(n int) string {
|
||||
return CountWord(n, "устройство", "устройства", "устройств")
|
||||
}
|
||||
func Devices(n int) string { return say.Devices(n) }
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"variants": ["не знаю — не нашла у тебя такой записи."]
|
||||
},
|
||||
"fact_when": {
|
||||
"fixed": true,
|
||||
"variants": ["записала это {when}"]
|
||||
},
|
||||
"fact_value": {
|
||||
@@ -40,9 +41,11 @@
|
||||
"variants": ["эта страница закрыта для чтения — robots.txt не разрешает."]
|
||||
},
|
||||
"page_empty": {
|
||||
"fixed": true,
|
||||
"variants": ["страница открылась, но читать там нечего."]
|
||||
},
|
||||
"feeds_off": {
|
||||
"fixed": true,
|
||||
"variants": ["ленты не настроены."]
|
||||
},
|
||||
"feeds_new": {
|
||||
@@ -58,6 +61,7 @@
|
||||
"variants": ["в {location} сейчас {temp} {word}, {condition}.", "{location}: {temp} {word}, {condition}."]
|
||||
},
|
||||
"weather_off": {
|
||||
"fixed": true,
|
||||
"variants": ["погода не настроена."]
|
||||
},
|
||||
"weather_nolocation": {
|
||||
@@ -68,27 +72,35 @@
|
||||
"variants": ["в сети никого не нашла.", "в сети никого не нашла{tail}."]
|
||||
},
|
||||
"fail_plan": {
|
||||
"fixed": true,
|
||||
"variants": ["не получилось собрать план."]
|
||||
},
|
||||
"fail_notes": {
|
||||
"fixed": true,
|
||||
"variants": ["не получилось посмотреть записи."]
|
||||
},
|
||||
"fail_feeds": {
|
||||
"fixed": true,
|
||||
"variants": ["не получилось посмотреть ленты."]
|
||||
},
|
||||
"fail_calendar": {
|
||||
"fixed": true,
|
||||
"variants": ["не получилось проверить календарь."]
|
||||
},
|
||||
"fail_weather": {
|
||||
"fixed": true,
|
||||
"variants": ["не получилось узнать погоду."]
|
||||
},
|
||||
"fail_answer": {
|
||||
"fixed": true,
|
||||
"variants": ["не получилось найти ответ."]
|
||||
},
|
||||
"fail_page": {
|
||||
"fixed": true,
|
||||
"variants": ["не получилось прочитать страницу."]
|
||||
},
|
||||
"fail_netscan": {
|
||||
"fixed": true,
|
||||
"variants": ["не получилось просканировать сеть."]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package say
|
||||
|
||||
// The counted noun, in the form the number in front of it demands.
|
||||
//
|
||||
// Russian inflects a noun after a numeral, and the form depends on the last two
|
||||
// digits: 1 градус, 2 градуса, 5 градусов, 11 градусов, 21 градус, 22 градуса.
|
||||
// A line file cannot spell that out, so a count in a template splits into two
|
||||
// placeholders — the number, and {word} filled from here.
|
||||
//
|
||||
// The rule lived once as hostWord in cmd/mavend/netscan.go and once as
|
||||
// pluralDaysRU in internal/memory, which meant the weather line said "градусов"
|
||||
// for every temperature and the task list said "дн." — a written abbreviation
|
||||
// read aloud. One helper, every count site (Vikunja #521).
|
||||
|
||||
import "math"
|
||||
|
||||
// CountWord picks between the three forms n needs: one for 1, few for 2-4, many
|
||||
// for 0, 5-20 and anything ending in those. A negative count reads its own
|
||||
// magnitude, since minus does not change the noun: -2 градуса.
|
||||
func CountWord(n int, one, few, many string) string {
|
||||
if n < 0 {
|
||||
n = -n
|
||||
}
|
||||
if n%100 >= 11 && n%100 <= 14 {
|
||||
return many
|
||||
}
|
||||
switch n % 10 {
|
||||
case 1:
|
||||
return one
|
||||
case 2, 3, 4:
|
||||
return few
|
||||
default:
|
||||
return many
|
||||
}
|
||||
}
|
||||
|
||||
// Degrees — the noun for a temperature. Takes the reading as it arrives from a
|
||||
// weather provider and counts by the whole degrees she is about to say, so the
|
||||
// noun agrees with the number in the same sentence rather than with the reading
|
||||
// behind it.
|
||||
func Degrees(temp float64) string {
|
||||
return CountWord(int(math.Round(temp)), "градус", "градуса", "градусов")
|
||||
}
|
||||
|
||||
// Devices — the noun for a count of hosts on the LAN or of smart-home devices.
|
||||
func Devices(n int) string {
|
||||
return CountWord(n, "устройство", "устройства", "устройств")
|
||||
}
|
||||
|
||||
// Days — the noun for a stretch of days. This is what replaces «дн.» in the
|
||||
// overdue and due-soon reasons: an abbreviation is written shorthand, and every
|
||||
// one of these lines is spoken.
|
||||
func Days(n int) string {
|
||||
return CountWord(n, "день", "дня", "дней")
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package say
|
||||
|
||||
import "testing"
|
||||
|
||||
// The day forms, kept from internal/memory when the helper moved: 21 takes the
|
||||
// singular and 11 does not, which is the whole reason this is not n == 1.
|
||||
func TestDays(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
n int
|
||||
want string
|
||||
}{{1, "день"}, {2, "дня"}, {5, "дней"}, {11, "дней"}, {21, "день"}, {22, "дня"}, {114, "дней"}} {
|
||||
if got := Days(c.n); got != c.want {
|
||||
t.Errorf("Days(%d) = %q, want %q", c.n, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
-19
@@ -37,7 +37,6 @@ const (
|
||||
TasksCandidates = "tasks_candidates"
|
||||
|
||||
ReasonOverdue = "reason_overdue"
|
||||
ReasonOverdueDay = "reason_overdue_day"
|
||||
ReasonOverdueDays = "reason_overdue_days"
|
||||
ReasonToday = "reason_today"
|
||||
ReasonTomorrow = "reason_tomorrow"
|
||||
@@ -65,7 +64,7 @@ const (
|
||||
var summaryKeys = []string{
|
||||
PlanRestEmpty, PlanDayEmpty, PlanDay, PlanUncertain,
|
||||
TasksNone, TasksFirst, TasksCandidates,
|
||||
ReasonOverdue, ReasonOverdueDay, ReasonOverdueDays, ReasonToday, ReasonTomorrow,
|
||||
ReasonOverdue, ReasonOverdueDays, ReasonToday, ReasonTomorrow,
|
||||
ReasonInDays, ReasonImportant, ReasonUrgent, ReasonStale,
|
||||
HabitWeekday, HabitWeekdaySame, HabitWeekdayNone,
|
||||
HabitWeekendBoth, HabitWeekendSat, HabitWeekendSun, HabitWeekendSame, HabitWeekendNone,
|
||||
@@ -74,37 +73,38 @@ var summaryKeys = []string{
|
||||
}
|
||||
|
||||
// summaryFloor — the literal each key falls back to when the file is unusable.
|
||||
// These are the exact strings that lived in Go before this file existed.
|
||||
// It started as the exact strings that lived in Go before this file existed and
|
||||
// now tracks the file's first variant instead, because a floor that keeps the
|
||||
// wording review threw out would say it back on the one turn nobody is watching.
|
||||
var summaryFloor = map[string]string{
|
||||
PlanRestEmpty: "на сегодня больше ничего не запланировано.",
|
||||
PlanDayEmpty: "на {date} ничего не запланировано.",
|
||||
PlanDay: "план на {date}: {items}.",
|
||||
PlanDay: "план на {date}: {items}",
|
||||
PlanUncertain: "похоже, {line}",
|
||||
|
||||
TasksNone: "задач нет.",
|
||||
TasksFirst: "сначала: {items}.",
|
||||
TasksCandidates: "ещё я нашла, но ты не подтвердил: {items}.",
|
||||
TasksFirst: "сначала: {items}",
|
||||
TasksCandidates: "нашла ещё, но ты не подтверждал: {items}",
|
||||
|
||||
ReasonOverdue: "просрочено",
|
||||
ReasonOverdueDay: "просрочено на день",
|
||||
ReasonOverdueDays: "просрочено на {n} дн.",
|
||||
ReasonOverdueDays: "просрочено на {n} {word}",
|
||||
ReasonToday: "сегодня",
|
||||
ReasonTomorrow: "завтра",
|
||||
ReasonInDays: "через {n} дн.",
|
||||
ReasonInDays: "через {n} {word}",
|
||||
ReasonImportant: "важно",
|
||||
ReasonUrgent: "срочно",
|
||||
ReasonStale: "давно в списке",
|
||||
|
||||
HabitWeekday: "по {day} ты обычно {items}.",
|
||||
HabitWeekdaySame: "по {day} у тебя нет ничего особенного — то же, что и в остальные дни: {items}.",
|
||||
HabitWeekdayNone: "по {day} я пока не вижу у тебя ничего постоянного.",
|
||||
HabitWeekendBoth: "по субботам ты обычно {sat}, по воскресеньям — {sun}.",
|
||||
HabitWeekendSat: "по субботам ты обычно {items}, а по воскресеньям ничего постоянного.",
|
||||
HabitWeekendSun: "по воскресеньям ты обычно {items}, а по субботам ничего постоянного.",
|
||||
HabitWeekendSame: "по выходным у тебя нет ничего особенного — то же, что и в остальные дни: {items}.",
|
||||
HabitWeekendNone: "по выходным я пока не вижу у тебя ничего постоянного.",
|
||||
HabitWeekdaySame: "по {day} всё как обычно — то же, что и в остальные дни: {items}.",
|
||||
HabitWeekdayNone: "по {day} я пока не вижу ничего постоянного.",
|
||||
HabitWeekendBoth: "по субботам ты обычно {items_sat}, по воскресеньям — {items_sun}.",
|
||||
HabitWeekendSat: "по субботам ты обычно {items}, а по воскресеньям постоянного нет.",
|
||||
HabitWeekendSun: "по воскресеньям ты обычно {items}, а по субботам постоянного нет.",
|
||||
HabitWeekendSame: "по выходным всё как обычно — то же, что и в остальные дни: {items}.",
|
||||
HabitWeekendNone: "по выходным я пока не вижу ничего постоянного.",
|
||||
HabitOverall: "обычно ты {items} — {span}.",
|
||||
HabitOverallNone: "я ещё не набрала достаточно записей, чтобы говорить о привычках.",
|
||||
HabitOverallNone: "записей пока мало, про привычки не скажу.",
|
||||
HabitSpanToday: "по записям за сегодня",
|
||||
HabitSpanDays: "по записям за последние {n} {word}",
|
||||
HabitUnglossed: "отмечаешь «{key}»",
|
||||
@@ -128,11 +128,12 @@ func LoadSummaries(src rand.Source) (*Summaries, error) {
|
||||
{PlanDayEmpty, "{date}"}, {PlanDay, "{date}"}, {PlanDay, "{items}"},
|
||||
{PlanUncertain, "{line}"},
|
||||
{TasksFirst, "{items}"}, {TasksCandidates, "{items}"},
|
||||
{ReasonOverdueDays, "{n}"}, {ReasonInDays, "{n}"},
|
||||
{ReasonOverdueDays, "{n}"}, {ReasonOverdueDays, "{word}"},
|
||||
{ReasonInDays, "{n}"}, {ReasonInDays, "{word}"},
|
||||
{HabitWeekday, "{day}"}, {HabitWeekday, "{items}"},
|
||||
{HabitWeekdaySame, "{day}"}, {HabitWeekdaySame, "{items}"},
|
||||
{HabitWeekdayNone, "{day}"},
|
||||
{HabitWeekendBoth, "{sat}"}, {HabitWeekendBoth, "{sun}"},
|
||||
{HabitWeekendBoth, "{items_sat}"}, {HabitWeekendBoth, "{items_sun}"},
|
||||
{HabitWeekendSat, "{items}"}, {HabitWeekendSun, "{items}"},
|
||||
{HabitWeekendSame, "{items}"},
|
||||
{HabitOverall, "{items}"}, {HabitOverall, "{span}"},
|
||||
|
||||
@@ -5,9 +5,13 @@
|
||||
"The sentences she builds around aggregated data: the morning plan, the ranked task list, and the habits she reads back out of behaviour records.",
|
||||
"Rules: she is feminine about herself, he is a man addressed as ты. Never вы/вас/ваш, never он/его about him. No pet names.",
|
||||
"\"I have not seen enough yet\" and \"there is nothing there\" are different claims, and the habit entries keep the first. Three days of taps do not license a statement about his life, so habit_*_none says she does not see a pattern, never that he has no habits.",
|
||||
"Placeholders: {date} a formatted date, {items} a joined list, {day} a weekday name, {span} the stretch of records a habit claim rests on, {n} a count, {word} a Russian count form built Go-side.",
|
||||
"The count forms (день/дня/дней, задача/задачи/задач) are morphology, not copy. They stay in Go and arrive here through {word}.",
|
||||
"fixed: true means exactly one variant and no picking. Used where the wording is the distinction: the day that is over versus the day that was empty, and the list of tasks he never confirmed."
|
||||
"She reports on the records, not on him. «всё как обычно» is a claim about what she has stored; «у тебя нет ничего особенного» is a verdict on his life, and she does not pass those.",
|
||||
"Placeholders: {date} a formatted date, {items} a joined list, {items_sat} and {items_sun} the same for one weekend day each, {day} a weekday name, {span} the stretch of records a habit claim rests on, {line} one already-rendered plan line, {key} a behaviour key she has no gloss for, {gloss} the readable name of one, {time} a clock time, {n} a count, {word} the Russian count form for {n}, built Go-side.",
|
||||
"Three parallel entries share one scheme: habit_weekend_both names its two lists {items_sat} and {items_sun}, and habit_weekend_sat and habit_weekend_sun each use {items}, because each of them says one list.",
|
||||
"The count forms (день/дня/дней) are morphology, not copy. They stay in Go and arrive here through {word}. An abbreviation is not an option: «дн.» is written shorthand and every line here is spoken, so it reads as garbage or gets spelled out letter by letter.",
|
||||
"{line} in plan_uncertain is another rendered line, so the join reads as one sentence only while what arrives starts lowercase. The caller asserts that.",
|
||||
"A joined list arrives with its own punctuation, so the entries that end on {items} carry no trailing period. The habit entries keep theirs, because there the list sits mid-sentence.",
|
||||
"fixed: true means exactly one variant and no picking. Parallel entries agree on it, and a single-variant entry is fixed — in this file and in every other family. A test holds that."
|
||||
],
|
||||
"entries": {
|
||||
"plan_rest_empty": {
|
||||
@@ -20,7 +24,7 @@
|
||||
},
|
||||
"plan_day": {
|
||||
"fixed": true,
|
||||
"variants": ["план на {date}: {items}."]
|
||||
"variants": ["план на {date}: {items}"]
|
||||
},
|
||||
"plan_uncertain": {
|
||||
"fixed": true,
|
||||
@@ -33,24 +37,20 @@
|
||||
},
|
||||
"tasks_first": {
|
||||
"fixed": true,
|
||||
"variants": ["сначала: {items}."]
|
||||
"variants": ["сначала: {items}"]
|
||||
},
|
||||
"tasks_candidates": {
|
||||
"fixed": true,
|
||||
"variants": ["ещё я нашла, но ты не подтвердил: {items}."]
|
||||
"variants": ["нашла ещё, но ты не подтверждал: {items}"]
|
||||
},
|
||||
|
||||
"reason_overdue": {
|
||||
"fixed": true,
|
||||
"variants": ["просрочено"]
|
||||
},
|
||||
"reason_overdue_day": {
|
||||
"fixed": true,
|
||||
"variants": ["просрочено на день"]
|
||||
},
|
||||
"reason_overdue_days": {
|
||||
"fixed": true,
|
||||
"variants": ["просрочено на {n} дн."]
|
||||
"variants": ["просрочено на {n} {word}"]
|
||||
},
|
||||
"reason_today": {
|
||||
"fixed": true,
|
||||
@@ -62,7 +62,7 @@
|
||||
},
|
||||
"reason_in_days": {
|
||||
"fixed": true,
|
||||
"variants": ["через {n} дн."]
|
||||
"variants": ["через {n} {word}"]
|
||||
},
|
||||
"reason_important": {
|
||||
"fixed": true,
|
||||
@@ -82,51 +82,53 @@
|
||||
"variants": ["по {day} ты обычно {items}."]
|
||||
},
|
||||
"habit_weekday_same": {
|
||||
"variants": [
|
||||
"по {day} у тебя нет ничего особенного — то же, что и в остальные дни: {items}.",
|
||||
"по {day} всё как обычно — то же, что и в остальные дни: {items}."
|
||||
]
|
||||
"fixed": true,
|
||||
"variants": ["по {day} всё как обычно — то же, что и в остальные дни: {items}."]
|
||||
},
|
||||
"habit_weekday_none": {
|
||||
"variants": [
|
||||
"по {day} я пока не вижу у тебя ничего постоянного.",
|
||||
"по {day} у тебя пока ничего постоянного не вижу — записей мало."
|
||||
"по {day} я пока не вижу ничего постоянного.",
|
||||
"по {day} постоянного пока не вижу — записей мало."
|
||||
]
|
||||
},
|
||||
"habit_weekend_both": {
|
||||
"variants": ["по субботам ты обычно {sat}, по воскресеньям — {sun}."]
|
||||
"fixed": true,
|
||||
"variants": ["по субботам ты обычно {items_sat}, по воскресеньям — {items_sun}."]
|
||||
},
|
||||
"habit_weekend_sat": {
|
||||
"variants": ["по субботам ты обычно {items}, а по воскресеньям ничего постоянного."]
|
||||
"fixed": true,
|
||||
"variants": ["по субботам ты обычно {items}, а по воскресеньям постоянного нет."]
|
||||
},
|
||||
"habit_weekend_sun": {
|
||||
"variants": ["по воскресеньям ты обычно {items}, а по субботам ничего постоянного."]
|
||||
"fixed": true,
|
||||
"variants": ["по воскресеньям ты обычно {items}, а по субботам постоянного нет."]
|
||||
},
|
||||
"habit_weekend_same": {
|
||||
"variants": [
|
||||
"по выходным у тебя нет ничего особенного — то же, что и в остальные дни: {items}.",
|
||||
"по выходным всё как обычно — то же, что и в остальные дни: {items}."
|
||||
]
|
||||
"fixed": true,
|
||||
"variants": ["по выходным всё как обычно — то же, что и в остальные дни: {items}."]
|
||||
},
|
||||
"habit_weekend_none": {
|
||||
"variants": [
|
||||
"по выходным я пока не вижу у тебя ничего постоянного.",
|
||||
"по выходным у тебя пока ничего постоянного не вижу — записей мало."
|
||||
"по выходным я пока не вижу ничего постоянного.",
|
||||
"по выходным постоянного пока не вижу — записей мало."
|
||||
]
|
||||
},
|
||||
"habit_overall": {
|
||||
"fixed": true,
|
||||
"variants": ["обычно ты {items} — {span}."]
|
||||
},
|
||||
"habit_overall_none": {
|
||||
"variants": [
|
||||
"я ещё не набрала достаточно записей, чтобы говорить о привычках.",
|
||||
"записей пока мало — на привычки я так не сошлюсь."
|
||||
"записей пока мало, про привычки не скажу.",
|
||||
"пока мало записей, чтобы говорить о привычках."
|
||||
]
|
||||
},
|
||||
"habit_span_today": {
|
||||
"fixed": true,
|
||||
"variants": ["по записям за сегодня"]
|
||||
},
|
||||
"habit_span_days": {
|
||||
"fixed": true,
|
||||
"variants": ["по записям за последние {n} {word}"]
|
||||
},
|
||||
"habit_unglossed": {
|
||||
|
||||
@@ -47,3 +47,16 @@ func TestHabitGapsSaySheHasNotSeenEnough(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// One variant means fixed, in this file and in the four in internal/phraser.
|
||||
// Nothing breaks on the flag being absent, but parallel entries disagreeing
|
||||
// about it is how the file stops telling a reader which wording is load-bearing.
|
||||
func TestEverySingleVariantEntryIsFixed(t *testing.T) {
|
||||
s, err := LoadSummaries(rand.NewSource(1))
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if got := s.d.UnfixedSingles(); len(got) > 0 {
|
||||
t.Errorf("single-variant entries not marked fixed: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
+15
-5
@@ -121,10 +121,12 @@ func score(it Item, now time.Time) (float64, string) {
|
||||
}
|
||||
total += scoreOverdue + bonus
|
||||
reason = say.S(say.ReasonOverdue, nil)
|
||||
if late == 1 {
|
||||
reason = say.S(say.ReasonOverdueDay, nil)
|
||||
} else if late > 1 {
|
||||
reason = say.S(say.ReasonOverdueDays, map[string]string{"n": strconv.Itoa(late)})
|
||||
if late > 0 {
|
||||
// One day needs no arm of its own: «просрочено на 1 день»
|
||||
// falls out of the count helper like every other number.
|
||||
reason = say.S(say.ReasonOverdueDays, map[string]string{
|
||||
"n": strconv.Itoa(late), "word": say.Days(late),
|
||||
})
|
||||
}
|
||||
case days == 0:
|
||||
total += scoreDueToday
|
||||
@@ -134,7 +136,9 @@ func score(it Item, now time.Time) (float64, string) {
|
||||
reason = say.S(say.ReasonTomorrow, nil)
|
||||
case days <= 7:
|
||||
total += scoreDueWeek
|
||||
reason = say.S(say.ReasonInDays, map[string]string{"n": strconv.Itoa(days)})
|
||||
reason = say.S(say.ReasonInDays, map[string]string{
|
||||
"n": strconv.Itoa(days), "word": say.Days(days),
|
||||
})
|
||||
default:
|
||||
total += scoreDueLater
|
||||
}
|
||||
@@ -223,7 +227,13 @@ func FormatRU(ranked []Ranked) string {
|
||||
}))
|
||||
}
|
||||
if len(cands) > 0 {
|
||||
// Two sentences, and the first one ends on a joined list that carries
|
||||
// whatever punctuation its last task had — usually none. So the break
|
||||
// is the caller's to make, not the line file's (Vikunja #521).
|
||||
if b.Len() > 0 {
|
||||
if !strings.HasSuffix(b.String(), ".") {
|
||||
b.WriteString(".")
|
||||
}
|
||||
b.WriteString(" ")
|
||||
}
|
||||
b.WriteString(say.S(say.TasksCandidates, map[string]string{
|
||||
|
||||
@@ -123,10 +123,12 @@ func TestRankOverdueDaysAreCounted(t *testing.T) {
|
||||
if got[0].Text != "давно" {
|
||||
t.Errorf("order = %v, want the later-overdue task first", texts(got))
|
||||
}
|
||||
if got[0].Reason != "просрочено на 12 дн." {
|
||||
if got[0].Reason != "просрочено на 12 дней" {
|
||||
t.Errorf("reason = %q", got[0].Reason)
|
||||
}
|
||||
if got[1].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)
|
||||
}
|
||||
}
|
||||
@@ -141,7 +143,7 @@ func TestFormatRUNamesReasonsAndSeparatesCandidates(t *testing.T) {
|
||||
if !strings.HasPrefix(got, "сначала: оплатить интернет (сегодня)") {
|
||||
t.Errorf("reply = %q", got)
|
||||
}
|
||||
if !strings.Contains(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.
|
||||
@@ -178,7 +180,7 @@ func TestFormatRUEmpty(t *testing.T) {
|
||||
|
||||
// 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 "просрочено на день" on the due date
|
||||
// "сегодня" 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)
|
||||
|
||||
Reference in New Issue
Block a user