Risk tiers: take Hexis's tier for a Hexis capability, derive only for local tool rows #162
@@ -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