- suite retire снимает правило, конвенцию или тему: правило остаётся заглушкой с датой и причиной, имя уезжает в раздел выбывших, снятие с непогашенными ссылками отклоняется с перечнем мест - добавлена проверка META-30: короткое описание языка обязано называть все слова словаря, иначе читатель копии толкует их по памяти - retired-раздел манифеста больше не считается объявлением пути: снятый префикс означает, что файл ушёл вместе с ним
229 lines
8.8 KiB
Go
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")
|
|
}
|
|
}
|