suite rule и suite list

- suite rule дописывает правило: номер берётся следующим за наибольшим, блоки
  раскладываются в порядке норма, ПОЧЕМУ, ПРИМЕРЫ, --after ставит правило рядом
  с тем, которое оно уточняет
- ступень называется категорией (requirement, prohibition, ...), а не словом
  языка, поэтому вызывающему не нужно знать, на каком языке записан набор
- suite list показывает темы со слоями, а с осью — что возьмёт компонент; отбор
  слоёв вынесен в suite.Assemble, откуда его возьмут проектные команды
- слои темы теперь всегда возвращаются базовым вперёд
This commit is contained in:
av
2026-07-27 11:36:19 +03:00
parent 5321fba89d
commit 8331aa1ca5
8 changed files with 706 additions and 6 deletions
+201
View File
@@ -0,0 +1,201 @@
package cli_test
import (
"strings"
"testing"
"git.vakhrushev.me/av/convy/internal/cli"
)
func TestRuleTakesTheNextNumberAndKeepsTheOrderOfBlocks(t *testing.T) {
root := retirable(t)
code, out := run(t, root, "", false, "suite", "rule",
"--prefix", "TIME", "--title", "Точность носителя фиксируется",
"--modality", "requirement",
"--norm", "У колонки БД и у потока логов точность объявлена и не плавает.",
"--why", "Плавающая точность ломает сортировку выборочно и невоспроизводимо.")
if code != cli.OK {
t.Fatalf("adding a rule returned %d: %s", code, out)
}
if !strings.Contains(out, "TIME-3 added") {
t.Errorf("the number is not the next after the highest:\n%s", out)
}
body := read(t, root, "conventions/time.md")
want := "### TIME-3. Точность носителя фиксируется\n\n**ДОЛЖЕН.** У колонки БД и у потока логов точность объявлена и не плавает.\n\n**ПОЧЕМУ.** Плавающая точность ломает сортировку выборочно и невоспроизводимо."
if !strings.Contains(body, want) {
t.Errorf("the rule is not laid out as the language fixes it:\n%s", body)
}
checkClean(t, root)
}
// A number is never reused, so retiring a rule does not free its number: the
// next one still comes after the highest.
func TestRuleNumbersPastARetiredOne(t *testing.T) {
root := retirable(t)
run(t, root, "", false, "suite", "retire", "--rule", "TIME-2", "--reason", "не нужно", "--date", "2026-07-27")
code, out := run(t, root, "", false, "suite", "rule",
"--prefix", "TIME", "--title", "Третье", "--modality", "recommendation",
"--norm", "Норма.", "--why", "Причина.")
if code != cli.OK {
t.Fatalf("adding a rule returned %d: %s", code, out)
}
if !strings.Contains(out, "TIME-3 added") {
t.Errorf("the number of a retired rule was reused:\n%s", out)
}
checkClean(t, root)
}
// Order in a file goes by reading rather than by number, so a rule elaborating
// another has to be placeable next to it.
func TestRulePlacesAfterTheOneItElaborates(t *testing.T) {
root := retirable(t)
run(t, root, "", false, "suite", "rule",
"--prefix", "TIME", "--title", "Третье", "--modality", "recommendation",
"--norm", "Норма третьего.", "--why", "Причина третьего.")
code, out := run(t, root, "", false, "suite", "rule",
"--prefix", "TIME", "--title", "Уточнение первого", "--modality", "permission",
"--norm", "Норма четвёртого.", "--why", "Причина четвёртого.",
"--after", "TIME-1")
if code != cli.OK {
t.Fatalf("adding a rule returned %d: %s", code, out)
}
body := read(t, root, "conventions/time.md")
order := []string{"### TIME-1.", "### TIME-4.", "### TIME-2.", "### TIME-3."}
at := 0
for _, id := range order {
i := strings.Index(body[at:], id)
if i < 0 {
t.Fatalf("%s is missing or out of place:\n%s", id, body)
}
at += i
}
checkClean(t, root)
}
// The step is named by its category rather than by a word of any one language,
// so that a caller need not know which language the suite is written in. The
// word itself is taken too, for whoever has it at hand.
func TestRuleTakesTheStepByCategoryOrByWord(t *testing.T) {
root := retirable(t)
run(t, root, "", false, "suite", "rule",
"--prefix", "SLOG", "--title", "Через категорию", "--modality", "not-recommended",
"--norm", "Норма.", "--why", "Причина.")
run(t, root, "", false, "suite", "rule",
"--prefix", "SLOG", "--title", "Через слово", "--modality", "ДОПУСКАЕТСЯ",
"--norm", "Норма.", "--why", "Причина.")
body := read(t, root, "conventions/logging.md")
for _, want := range []string{"**НЕ СЛЕДУЕТ.** Норма.", "**ДОПУСКАЕТСЯ.** Норма."} {
if !strings.Contains(body, want) {
t.Errorf("the step did not render as %q:\n%s", want, body)
}
}
checkClean(t, root)
}
func TestRuleRefusesWhatItCannotDo(t *testing.T) {
root := retirable(t)
cases := []struct {
name string
args []string
want string
}{{
name: "a prefix the suite does not declare",
args: []string{"--prefix", "ZZZZ", "--title", "Т", "--modality", "requirement", "--norm", "Н", "--why", "П"},
want: "no live prefix ZZZZ",
}, {
name: "a step that is not on the scale",
args: []string{"--prefix", "TIME", "--title", "Т", "--modality", "maybe", "--norm", "Н", "--why", "П"},
want: "one of: requirement, prohibition",
}, {
name: "placing after a rule of another file",
args: []string{"--prefix", "TIME", "--title", "Т", "--modality", "requirement", "--norm", "Н", "--why", "П", "--after", "SLOG-1"},
want: "belongs to another file",
}, {
name: "a rationale left out",
args: []string{"--prefix", "TIME", "--title", "Т", "--modality", "requirement", "--norm", "Н"},
want: "--why",
}}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
code, out := run(t, root, "", false, append([]string{"suite", "rule"}, tc.args...)...)
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)
}
})
}
checkClean(t, root)
}
func TestListShowsTheSuiteBaseLayerFirst(t *testing.T) {
root := retirable(t)
code, out := run(t, root, "", false, "suite", "list")
if code != cli.OK {
t.Fatalf("listing returned %d: %s", code, out)
}
for _, want := range []string{"time — время", "logging — логирование", "TIME", "GTIM", "lang=go", "base"} {
if !strings.Contains(out, want) {
t.Errorf("the listing lacks %q:\n%s", want, out)
}
}
if strings.Index(out, "conventions/time.md") > strings.Index(out, "conventions/lang/go/time.md") {
t.Errorf("a language layer came before the base one:\n%s", out)
}
}
// The selection is the read-only half of assembly: a layer travels when its
// axis keys agree with the component, and the base layer travels always.
func TestListSelectsWhatAComponentWouldTake(t *testing.T) {
root := retirable(t)
code, out := run(t, root, "", false, "suite", "list", "--topic", "time", "--lang", "go")
if code != cli.OK {
t.Fatalf("listing returned %d: %s", code, out)
}
if strings.Contains(out, "left out") {
t.Errorf("a component of the right language left a layer out:\n%s", out)
}
code, out = run(t, root, "", false, "suite", "list", "--topic", "time", "--lang", "python")
if code != cli.OK {
t.Fatalf("listing returned %d: %s", code, out)
}
if !strings.Contains(out, "conventions/lang/go/time.md") || !strings.Contains(out, "left out") {
t.Errorf("the go layer was not left out for a python component:\n%s", out)
}
if !strings.Contains(out, "conventions/time.md") {
t.Errorf("the base layer did not travel:\n%s", out)
}
}
func TestListShowsRetiredNames(t *testing.T) {
root := retirable(t)
code, out := run(t, root, "", false, "suite", "list", "--retired")
if code != cli.OK {
t.Fatalf("listing returned %d: %s", code, out)
}
if !strings.Contains(out, "nothing has been retired yet") {
t.Errorf("an untouched suite reported retirements:\n%s", out)
}
run(t, root, "", false, "suite", "retire", "--prefix", "SLOG", "--reason", "свёрнута", "--date", "2026-07-27")
_, out = run(t, root, "", false, "suite", "list", "--retired")
for _, want := range []string{"SLOG", "2026-07-27", "свёрнута", "ever handed out again"} {
if !strings.Contains(out, want) {
t.Errorf("the listing of retired names lacks %q:\n%s", want, out)
}
}
}