diff --git a/internal/phraser/act_lines_test.go b/internal/phraser/act_lines_test.go new file mode 100644 index 0000000..020698f --- /dev/null +++ b/internal/phraser/act_lines_test.go @@ -0,0 +1,86 @@ +package phraser + +import ( + "math/rand" + "strconv" + "strings" + "testing" +) + +func loadTestActs(t *testing.T) *Acts { + t.Helper() + a, err := LoadActs(rand.NewSource(1)) + if err != nil { + t.Fatalf("LoadActs: %v", err) + } + return a +} + +// She talks about a lamp or a server, never about a row in a schema. «сущность» +// and «экосистема» are the same defect as saying a capability id out loud. +func TestNoActLineSaysASchemaWord(t *testing.T) { + a := loadTestActs(t) + for _, v := range a.Variants() { + for _, word := range []string{"сущност", "экосистем"} { + if strings.Contains(v, word) { + t.Errorf("variant %q says %q out loud", v, word) + } + } + } +} + +// Nexus, Praxis and Hexis fail independently, so "не отвечает" with no subject +// is not an answer he can act on. +func TestAServiceFailureNamesTheService(t *testing.T) { + a := loadTestActs(t) + for _, key := range []string{EcoDown, EcoDenied} { + got := a.Say(key, map[string]string{"name": "Praxis"}) + if !strings.HasPrefix(got, "Praxis ") { + t.Errorf("%s = %q, want it to name the service", key, got) + } + } +} + +// A confirmation prompt for a destructive act is the worst place for an unfilled +// placeholder, so the two names it interpolates are distinct keys and both are +// declared. +func TestConfirmEntityFillsBothNames(t *testing.T) { + a := loadTestActs(t) + got := a.Say(ActConfirmEntity, map[string]string{ + "name": "restart", "name_entity": "Muzick indexer", + }) + if strings.ContainsAny(got, "{}") { + t.Fatalf("act_confirm_entity = %q, want no placeholder left", got) + } + if !strings.Contains(got, "restart") || !strings.Contains(got, "Muzick indexer") { + t.Fatalf("act_confirm_entity = %q, want both names", got) + } +} + +// home_dark counts unreachable devices, and Russian inflects the noun after the +// number: the count goes in {count} and the noun comes from the helper. +func TestHomeDarkCountsWithTheHelper(t *testing.T) { + a := loadTestActs(t) + for n, want := range map[int]string{1: "1 устройство", 2: "2 устройства", 5: "5 устройств"} { + got := a.Say(HomeDark, map[string]string{"count": strconv.Itoa(n), "word": Devices(n)}) + if !strings.Contains(got, want) { + t.Errorf("home_dark for %d = %q, want %q in it", n, got, want) + } + } +} + +// Four truths, four entries: a failure must not be able to report itself as a +// success, and an empty result must not read as a failure. +func TestActOutcomesStayDistinct(t *testing.T) { + a := loadTestActs(t) + seen := map[string]string{} + for _, key := range actKeys { + for _, v := range a.d.file.Entries[key].Variants { + if prev, dup := seen[v]; dup { + t.Errorf("%s and %s both say %q", prev, key, v) + } + seen[v] = key + } + } +} + diff --git a/internal/phraser/acts.go b/internal/phraser/acts.go index 9290c5f..b60c9c9 100644 --- a/internal/phraser/acts.go +++ b/internal/phraser/acts.go @@ -72,28 +72,30 @@ var actKeys = []string{ HomeUnreachable, HomeEmpty, HomeOn, HomeDark, } -// actFloor — 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. +// actFloor — the literal each key falls back to when the file is unusable. 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 actFloor = map[string]string{ ActDone: "готово.", ActDoneOut: "готово: {out}", - ActDoneEntity: "команда выполнена для {name}.", - ActConfirm: "выполнить «{name}»? скажи «да» или «нет».", - ActConfirmEntity: "выполнить «{name}» для {entity}? скажи «да» или «нет».", + ActDoneEntity: "готово: {name}.", + ActConfirm: "выполнить «{name}»? да или нет.", + ActConfirmEntity: "выполнить «{name}» для {name_entity}? да или нет.", ActWhich: "какую команду для {name}: {items}?", ActFail: "не получилось выполнить команду.", ActFailOut: "не получилось выполнить команду: {out}", ActFailEntity: "не получилось выполнить команду для {name}.", - ActServerDown: "этот инструмент включён, но сервер, который его выполняет, сейчас не подключён.", - ActWithdrawn: "сервер больше не предлагает этот инструмент — я сняла его с разрешённых, посмотри на /tools.", - ActNeedsArgs: "этому инструменту нужны аргументы, которые я из голоса не соберу — я не буду угадывать.", + ActServerDown: "инструмент есть, но сервер не подключён.", + ActWithdrawn: "сервер больше не отдаёт этот инструмент — сняла его с разрешённых, посмотри /tools.", + ActNeedsArgs: "тут нужны аргументы, из голоса не соберу. угадывать не буду.", - EcoDenied: "экосистема отклоняет доступ, проверь токен.", - EcoDown: "экосистема недоступна, попробуй ещё раз.", - EcoAmbiguous: "уточни, что именно: {items}?", - EcoUnknownEntity: "не знаю такой сущности.", - EcoNoNexus: "не могу связать это с сущностью — Nexus не настроен.", - EcoAboutWhat: "про что именно спросить?", + EcoDenied: "{name} отклоняет доступ, проверь токен.", + EcoDown: "{name} не отвечает, попробуй ещё раз.", + EcoAmbiguous: "что именно: {items}?", + EcoUnknownEntity: "не знаю, что это.", + EcoNoNexus: "не с чем связать — Nexus не настроен.", + EcoAboutWhat: "про что именно?", EcoRecall: "я помню: {items}", AttentionNone: "ничего не требует внимания.", @@ -102,13 +104,13 @@ var actFloor = map[string]string{ AttentionNoneEntity: "по «{name}» ничего нет.", AttentionListEntity: "по «{name}»: {items}", AttentionFailEntity: "не могу сейчас узнать, что требует внимания по «{name}».", - ChangesNone: "нет изменений.", + ChangesNone: "изменений нет.", ChangesList: "изменения: {items}", ChangesFail: "не могу сейчас узнать об изменениях.", - HomeUnreachable: "не смогла достучаться до дома.", + HomeUnreachable: "дом не отвечает.", HomeEmpty: "дом ничего не отдаёт.", HomeOn: "включено: {items}", - HomeDark: "дом молчит: {count} {word} не отвечают.", + HomeDark: "не отвечают: {count} {word}.", } // Acts picks a hand-written Russian act reply. Safe for concurrent use. @@ -126,10 +128,12 @@ func LoadActs(src rand.Source) (*Acts, error) { for _, req := range []struct{ key, ph string }{ {ActDoneOut, "{out}"}, {ActDoneEntity, "{name}"}, {ActFailOut, "{out}"}, {ActFailEntity, "{name}"}, {ActConfirm, "{name}"}, - {ActConfirmEntity, "{name}"}, {ActConfirmEntity, "{entity}"}, + {ActConfirmEntity, "{name}"}, {ActConfirmEntity, "{name_entity}"}, {ActWhich, "{name}"}, {ActWhich, "{items}"}, {EcoAmbiguous, "{items}"}, {EcoRecall, "{items}"}, {AttentionList, "{items}"}, {ChangesList, "{items}"}, {HomeOn, "{items}"}, + {EcoDenied, "{name}"}, {EcoDown, "{name}"}, + {HomeDark, "{count}"}, {HomeDark, "{word}"}, {AttentionNoneEntity, "{name}"}, {AttentionListEntity, "{name}"}, {AttentionListEntity, "{items}"}, {AttentionFailEntity, "{name}"}, } { diff --git a/internal/phraser/acts_ru_v1.json b/internal/phraser/acts_ru_v1.json index afcfbc2..e7597f2 100644 --- a/internal/phraser/acts_ru_v1.json +++ b/internal/phraser/acts_ru_v1.json @@ -4,9 +4,13 @@ "notes": [ "What she says when a capability ran, refused, or could not be reached. Edit the wording here, no Go changes needed.", "Rules: she is feminine about herself, he is a man addressed as ты. Never вы/вас/ваш, never он/его about him. No pet names.", - "\"it ran\", \"it was refused\", \"the ecosystem is down\" and \"I could not work out what you meant\" are four different truths. They keep four entries, because one variant set would let a failure report itself as a success.", - "Placeholders: {name} an entity or capability the caller resolved, {out} the command's own output, {items} a joined list, {count} a number. Entity names and capability ids are interpolated Go-side.", - "fixed: true means exactly one variant and no picking. Used where the wording carries an instruction he has to act on — a confirmation, a pointer at /tools — and for the two lines that report an act as done, because a success report that reworded itself is harder to trust and harder to test." + "\"it ran\", \"it was refused\", \"a service is down\" and \"I could not work out what you meant\" are four different truths. They keep four entries, because one variant set would let a failure report itself as a success.", + "She says what he would say. No schema words out loud: not «сущность», not «экосистема», not a capability id, not a config key. She is talking about a lamp or a server.", + "A service that is down or refusing is named. \"не отвечает\" with no subject tells him nothing he can act on, and Nexus, Praxis and Hexis fail independently.", + "Placeholders: {name} an entity or capability the caller resolved, {name_entity} the entity an act runs against when {name} is already the capability, {out} the command's own output, {items} a joined list, {count} a number, {word} the counted noun in the form {count} needs. Entity names and capability ids are interpolated Go-side.", + "A count never carries a hardcoded noun. Russian inflects it — 1 устройство, 2 устройства, 5 устройств — so the number goes in {count} and the noun comes from the Go helper through {word}.", + "An entry that only exists to read a list back must never be reached with an empty list. The caller routes an empty list to the matching _none entry, because a single-variant placeholder-only line has no shorter wording to fall back to.", + "fixed: true means exactly one variant and no picking. Used where the wording carries an instruction he has to act on — a confirmation, a pointer at /tools — and for the lines that report an act as done, because a success report that reworded itself is harder to trust and harder to test." ], "entries": { "act_done": { @@ -18,61 +22,61 @@ }, "act_done_entity": { "fixed": true, - "variants": ["команда выполнена для {name}."] + "variants": ["готово: {name}."] }, "act_confirm": { "fixed": true, - "variants": ["выполнить «{name}»? скажи «да» или «нет»."] + "variants": ["выполнить «{name}»? да или нет."] }, "act_confirm_entity": { "fixed": true, - "variants": ["выполнить «{name}» для {entity}? скажи «да» или «нет»."] + "variants": ["выполнить «{name}» для {name_entity}? да или нет."] }, "act_which": { "variants": ["какую команду для {name}: {items}?"] }, "act_fail": { - "variants": ["не получилось выполнить команду.", "команда не выполнилась."] + "variants": ["не получилось выполнить команду."] }, "act_fail_out": { "variants": ["не получилось выполнить команду: {out}"] }, "act_fail_entity": { - "variants": ["не получилось выполнить команду для {name}.", "команда для {name} не выполнилась."] + "variants": ["не получилось выполнить команду для {name}."] }, "act_server_down": { - "variants": ["этот инструмент включён, но сервер, который его выполняет, сейчас не подключён."] + "variants": ["инструмент есть, но сервер не подключён."] }, "act_withdrawn": { "fixed": true, - "variants": ["сервер больше не предлагает этот инструмент — я сняла его с разрешённых, посмотри на /tools."] + "variants": ["сервер больше не отдаёт этот инструмент — сняла его с разрешённых, посмотри /tools."] }, "act_needs_args": { - "variants": ["этому инструменту нужны аргументы, которые я из голоса не соберу — я не буду угадывать."] + "variants": ["тут нужны аргументы, из голоса не соберу. угадывать не буду."] }, "eco_denied": { - "variants": ["экосистема отклоняет доступ, проверь токен."] + "variants": ["{name} отклоняет доступ, проверь токен."] }, "eco_down": { - "variants": ["экосистема недоступна, попробуй ещё раз.", "экосистема не отвечает, попробуй ещё раз."] + "variants": ["{name} не отвечает, попробуй ещё раз."] }, "eco_ambiguous": { - "variants": ["уточни, что именно: {items}?", "что именно из этого: {items}?"] + "variants": ["что именно: {items}?"] }, "eco_unknown_entity": { - "variants": ["не знаю такой сущности.", "такой сущности у меня нет."] + "variants": ["не знаю, что это.", "такого у меня нет."] }, "eco_no_nexus": { - "variants": ["не могу связать это с сущностью — Nexus не настроен."] + "variants": ["не с чем связать — Nexus не настроен."] }, "eco_about_what": { - "variants": ["про что именно спросить?", "про что спросить?"] + "variants": ["про что именно?"] }, "eco_recall": { "variants": ["я помню: {items}"] }, "attention_none": { - "variants": ["ничего не требует внимания.", "внимания сейчас ничего не требует."] + "variants": ["ничего не требует внимания."] }, "attention_list": { "variants": ["требует внимания: {items}"] @@ -90,7 +94,7 @@ "variants": ["не могу сейчас узнать, что требует внимания по «{name}»."] }, "changes_none": { - "variants": ["нет изменений.", "изменений нет."] + "variants": ["изменений нет."] }, "changes_list": { "variants": ["изменения: {items}"] @@ -99,16 +103,16 @@ "variants": ["не могу сейчас узнать об изменениях."] }, "home_unreachable": { - "variants": ["не смогла достучаться до дома.", "дом не отвечает."] + "variants": ["дом не отвечает.", "не достучалась до дома."] }, "home_empty": { - "variants": ["дом ничего не отдаёт.", "дом молчит."] + "variants": ["дом ничего не отдаёт."] }, "home_on": { "variants": ["включено: {items}"] }, "home_dark": { - "variants": ["дом молчит: {count} {word} не отвечают."] + "variants": ["не отвечают: {count} {word}."] } } }