Files
convy/internal/cli/retire_test.go
T
av 5321fba89d suite retire и проверка словаря в документе для читателя
- suite retire снимает правило, конвенцию или тему: правило остаётся заглушкой
  с датой и причиной, имя уезжает в раздел выбывших, снятие с непогашенными
  ссылками отклоняется с перечнем мест
- добавлена проверка META-30: короткое описание языка обязано называть все
  слова словаря, иначе читатель копии толкует их по памяти
- retired-раздел манифеста больше не считается объявлением пути: снятый
  префикс означает, что файл ушёл вместе с ним
2026-07-27 10:51:46 +03:00

229 lines
8.8 KiB
Go

package cli_test
import (
"os"
"path/filepath"
"strings"
"testing"
"git.vakhrushev.me/av/convy/internal/cli"
)
// retirable builds a suite with two topics, three conventions and a couple of
// rules, which is the least that lets every case of retirement be told apart.
func retirable(t *testing.T) string {
t.Helper()
root := filepath.Join(t.TempDir(), "suite")
run(t, ".", "", false, "suite", "init", "--path", root, "--lang", "ru")
run(t, root, "", false, "suite", "add", "--topic", "time", "--about", "время", "--prefix", "TIME", "--title", "Время")
run(t, root, "", false, "suite", "add", "--topic", "time", "--prefix", "GTIM", "--lang", "go", "--title", "Время на Go")
run(t, root, "", false, "suite", "add", "--topic", "logging", "--about", "логирование", "--prefix", "SLOG", "--title", "Логирование")
appendRules(t, root, "conventions/time.md", `
### TIME-1. Момент записывается в UTC
**ДОЛЖЕН.** Момент времени записывается с суффиксом Z.
**ПОЧЕМУ.** Без явного смещения не видно, в какой зоне запись сделана.
### TIME-2. Ширина строки фиксируется
**ДОЛЖЕН.** Внутри одной колонки длина строки времени одна.
**ПОЧЕМУ.** Лексикографическая сортировка совпадает с хронологией только среди
строк одинаковой длины.
`)
appendRules(t, root, "conventions/logging.md", `
### SLOG-1. Уровень выбирается по адресату
**ДОЛЖЕН.** Уровень отвечает на вопрос «кому сообщение».
**ПОЧЕМУ.** Адресат — единственный воспроизводимый признак.
`)
return root
}
func appendRules(t *testing.T, root, rel, rules string) {
t.Helper()
name := filepath.Join(root, filepath.FromSlash(rel))
body, err := os.ReadFile(name)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(name, append(body, []byte("\n## Правила\n"+rules)...), 0o644); err != nil {
t.Fatal(err)
}
}
func TestRetireRuleLeavesAStub(t *testing.T) {
root := retirable(t)
code, out := run(t, root, "", false, "suite", "retire",
"--rule", "TIME-2", "--reason", "ширина следует из TIME-1 и отдельного правила не требует",
"--date", "2026-07-27")
if code != cli.OK {
t.Fatalf("retiring a rule returned %d: %s", code, out)
}
body := read(t, root, "conventions/time.md")
if !strings.Contains(body, "### TIME-2. Ширина строки фиксируется") {
t.Errorf("the heading and the number did not survive:\n%s", body)
}
if !strings.Contains(body, "**СНЯТО 2026-07-27.** ширина следует из TIME-1") {
t.Errorf("the stub is not there or is worded wrong:\n%s", body)
}
if strings.Contains(body, "Лексикографическая сортировка") {
t.Errorf("the rationale of the retired rule survived:\n%s", body)
}
if strings.Contains(body, "**ДОЛЖЕН.** Внутри одной колонки") {
t.Errorf("the norm of the retired rule survived:\n%s", body)
}
// The numbering stays contiguous and the whole suite still checks clean —
// which is the point of a stub over a deletion.
checkClean(t, root)
}
func TestRetireRuleRefusesWhatItCannotDo(t *testing.T) {
root := retirable(t)
run(t, root, "", false, "suite", "retire", "--rule", "TIME-2", "--reason", "уже снято", "--date", "2026-07-27")
cases := []struct{ name, id, want string }{
{"a rule already retired", "TIME-2", "retired already"},
{"a number no rule carries", "TIME-9", "holds no rule numbered 9"},
{"a prefix the suite does not declare", "ZZZZ-1", "no live prefix ZZZZ"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
code, out := run(t, root, "", false, "suite", "retire", "--rule", tc.id, "--reason", "почему-то")
if code == cli.OK {
t.Fatalf("the command went through:\n%s", out)
}
if !strings.Contains(out, tc.want) {
t.Errorf("the refusal does not say %q:\n%s", tc.want, out)
}
})
}
}
// A file takes its rules with it, and nothing stands in for a file the way a
// stub stands in for a rule. So a convention still pointed at cannot go.
func TestRetirePrefixRefusesWhileStillPointedAt(t *testing.T) {
root := retirable(t)
name := filepath.Join(root, "conventions", "logging.md")
body, _ := os.ReadFile(name)
if err := os.WriteFile(name, append(body,
[]byte("\nВремя в записи следует TIME-1.\n")...), 0o644); err != nil {
t.Fatal(err)
}
code, out := run(t, root, "", false, "suite", "retire", "--prefix", "TIME", "--reason", "тема закрыта")
if code == cli.OK {
t.Fatalf("a convention still pointed at was retired:\n%s", out)
}
for _, want := range []string{"still pointed at", "conventions/logging.md", "TIME-1"} {
if !strings.Contains(out, want) {
t.Errorf("the refusal does not say %q:\n%s", want, out)
}
}
if _, err := os.Stat(filepath.Join(root, "conventions", "time.md")); err != nil {
t.Error("the file was removed despite the refusal")
}
}
func TestRetirePrefixMovesTheNameAndTakesTheFile(t *testing.T) {
root := retirable(t)
code, out := run(t, root, "", false, "suite", "retire",
"--prefix", "SLOG", "--reason", "тема свёрнута", "--date", "2026-07-27")
if code != cli.OK {
t.Fatalf("retiring a convention returned %d: %s", code, out)
}
if _, err := os.Stat(filepath.Join(root, "conventions", "logging.md")); !os.IsNotExist(err) {
t.Error("the file stayed behind")
}
toml := read(t, root, "suite.toml")
if strings.Contains(toml, `SLOG = "conventions/logging.md"`) {
t.Errorf("the prefix stayed in the live half:\n%s", toml)
}
if !strings.Contains(toml, `SLOG = "2026-07-27, was conventions/logging.md: тема свёрнута"`) {
t.Errorf("the retired half does not carry the date, the path and the reason:\n%s", toml)
}
if !strings.Contains(out, "no layer left") {
t.Errorf("the report does not point out that the topic is now empty:\n%s", out)
}
}
// A topic lives as long as one layer of it does, so the name cannot go first.
func TestRetireTopicWaitsForItsLayers(t *testing.T) {
root := retirable(t)
code, out := run(t, root, "", false, "suite", "retire", "--topic", "time", "--reason", "не нужна")
if code == cli.OK {
t.Fatalf("a topic with layers was retired:\n%s", out)
}
for _, want := range []string{"still holds layers", "conventions/time.md", "conventions/lang/go/time.md"} {
if !strings.Contains(out, want) {
t.Errorf("the refusal does not say %q:\n%s", want, out)
}
}
run(t, root, "", false, "suite", "retire", "--prefix", "GTIM", "--reason", "слой снят", "--date", "2026-07-27")
run(t, root, "", false, "suite", "retire", "--prefix", "TIME", "--reason", "слой снят", "--date", "2026-07-27")
code, out = run(t, root, "", false, "suite", "retire",
"--topic", "time", "--reason", "решение переехало в logging", "--date", "2026-07-27")
if code != cli.OK {
t.Fatalf("retiring an empty topic returned %d: %s", code, out)
}
toml := read(t, root, "suite.toml")
if strings.Contains(toml, `time = "время"`) {
t.Errorf("the topic stayed in the live half:\n%s", toml)
}
if !strings.Contains(toml, `time = "2026-07-27: решение переехало в logging"`) {
t.Errorf("the retired half does not carry the date and the reason:\n%s", toml)
}
checkClean(t, root)
}
func TestRetireNeedsExactlyOneTarget(t *testing.T) {
root := retirable(t)
code, out := run(t, root, "", false, "suite", "retire",
"--rule", "TIME-1", "--topic", "time", "--reason", "и то и другое")
if code == cli.OK {
t.Fatalf("two targets at once went through:\n%s", out)
}
if !strings.Contains(out, "exactly one") {
t.Errorf("the refusal does not say why:\n%s", out)
}
}
// Retirement is irreversible, so the dialogue asks before writing and takes
// silence for no.
func TestRetireInteractiveConfirms(t *testing.T) {
root := retirable(t)
code, out := run(t, root, "rule\nTIME-2\nбольше не нужно\n\n", true, "suite", "retire")
if code == cli.OK {
t.Fatalf("an unconfirmed retirement went through:\n%s", out)
}
if !strings.Contains(out, "nothing was written") {
t.Errorf("the report does not say that nothing happened:\n%s", out)
}
if strings.Contains(read(t, root, "conventions/time.md"), "**СНЯТО ") {
t.Error("the file was written despite no confirmation")
}
code, out = run(t, root, "rule\nTIME-2\nбольше не нужно\ny\n", true, "suite", "retire")
if code != cli.OK {
t.Fatalf("a confirmed retirement returned %d: %s", code, out)
}
if !strings.Contains(read(t, root, "conventions/time.md"), "**СНЯТО ") {
t.Error("a confirmed retirement wrote nothing")
}
}