- suite init создаёт директорию и манифест со скелетом таблиц; suite add пишет файл конвенции и вправляет запись в suite.toml, сохраняя комментарии - без аргументов команды спрашивают поля с подсказками, с флагами берут всё сразу и не спрашивают ничего; без терминала пустой вызов отказывает - строка о версии языка генерируется из словаря набора, поэтому созданный файл проходит suite check без правок - починено разрешение extends: короткая форма бралась по суффиксу и могла указать на сам файл; теперь неоднозначность либо избегается при записи, либо сообщается ошибкой
252 lines
8.8 KiB
Go
252 lines
8.8 KiB
Go
package cli_test
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.vakhrushev.me/av/convy/internal/check"
|
|
"git.vakhrushev.me/av/convy/internal/cli"
|
|
"git.vakhrushev.me/av/convy/internal/suite"
|
|
)
|
|
|
|
// run executes a command the way the process does, with input and output in
|
|
// hand. Interactive says whether a terminal is pretended to be there.
|
|
func run(t *testing.T, dir, input string, interactive bool, args ...string) (cli.ExitCode, string) {
|
|
t.Helper()
|
|
var out, errOut bytes.Buffer
|
|
env := cli.Env{
|
|
Dir: dir,
|
|
In: strings.NewReader(input),
|
|
Out: &out,
|
|
Err: &errOut,
|
|
Interactive: interactive,
|
|
}
|
|
code := cli.Run(env, args)
|
|
return code, out.String() + errOut.String()
|
|
}
|
|
|
|
func read(t *testing.T, parts ...string) string {
|
|
t.Helper()
|
|
body, err := os.ReadFile(filepath.Join(parts...))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return string(body)
|
|
}
|
|
|
|
// checkClean asserts that the suite the commands built passes every check.
|
|
// This is the invariant worth the most: what the tool writes, the tool accepts.
|
|
func checkClean(t *testing.T, root string) {
|
|
t.Helper()
|
|
s, err := suite.Load(root)
|
|
if err != nil {
|
|
t.Fatalf("loading the suite the commands built: %v", err)
|
|
}
|
|
rep := check.Suite(s)
|
|
if rep.Errors() > 0 || rep.Warnings() > 0 {
|
|
var b strings.Builder
|
|
for _, f := range rep.Findings() {
|
|
fmt.Fprintf(&b, " %s: %s\n", f.Path, f.Msg)
|
|
}
|
|
t.Fatalf("the suite the commands built does not check clean:\n%s", b.String())
|
|
}
|
|
}
|
|
|
|
func TestInitAndAddInAutomaticMode(t *testing.T) {
|
|
root := filepath.Join(t.TempDir(), "suite")
|
|
|
|
if code, out := run(t, ".", "", false, "suite", "init", "--path", root, "--lang", "ru"); code != cli.OK {
|
|
t.Fatalf("suite init returned %d: %s", code, out)
|
|
}
|
|
if code, out := run(t, root, "", false, "suite", "add",
|
|
"--topic", "time", "--about", "время: хранение и форматы",
|
|
"--prefix", "TIME", "--title", "Время",
|
|
"--intro", "Как приложение записывает моменты."); code != cli.OK {
|
|
t.Fatalf("suite add returned %d: %s", code, out)
|
|
}
|
|
if code, out := run(t, root, "", false, "suite", "add",
|
|
"--topic", "time", "--prefix", "GTIM", "--lang", "go",
|
|
"--title", "Время: реализация на Go"); code != cli.OK {
|
|
t.Fatalf("suite add of a layer returned %d: %s", code, out)
|
|
}
|
|
|
|
base := read(t, root, "conventions/time.md")
|
|
for _, want := range []string{"topic: time", "prefix: TIME", "# Время", "ДОЛЖЕН", "версии 1"} {
|
|
if !strings.Contains(base, want) {
|
|
t.Errorf("the base layer lost %q:\n%s", want, base)
|
|
}
|
|
}
|
|
if strings.Contains(base, "extends:") {
|
|
t.Errorf("the base layer got an extends key:\n%s", base)
|
|
}
|
|
|
|
layer := read(t, root, "conventions/lang/go/time.md")
|
|
for _, want := range []string{"lang: go", "extends: conventions/time.md"} {
|
|
if !strings.Contains(layer, want) {
|
|
t.Errorf("the language layer lost %q:\n%s", want, layer)
|
|
}
|
|
}
|
|
|
|
checkClean(t, root)
|
|
}
|
|
|
|
// The dialogue asks in the order the fields are declared, and a new topic gets
|
|
// one extra question about what it is for.
|
|
func TestAddInInteractiveMode(t *testing.T) {
|
|
root := filepath.Join(t.TempDir(), "suite")
|
|
if code, out := run(t, ".", "", false, "suite", "init", "--path", root, "--lang", "ru"); code != cli.OK {
|
|
t.Fatalf("suite init returned %d: %s", code, out)
|
|
}
|
|
|
|
answers := strings.Join([]string{
|
|
"logging", // topic
|
|
"логирование: уровни", // one line about a topic new to the suite
|
|
"SLOG", // prefix
|
|
"Логирование", // title
|
|
"", // language axis: the base layer
|
|
"", // stack axis
|
|
"Как приложение пишет записи.", // introductory prose
|
|
"", // path: the default offered
|
|
}, "\n") + "\n"
|
|
|
|
code, out := run(t, root, answers, true, "suite", "add")
|
|
if code != cli.OK {
|
|
t.Fatalf("the dialogue returned %d: %s", code, out)
|
|
}
|
|
if !strings.Contains(out, "One line about the topic") {
|
|
t.Errorf("a topic new to the suite was not asked about:\n%s", out)
|
|
}
|
|
|
|
body := read(t, root, "conventions/logging.md")
|
|
for _, want := range []string{"topic: logging", "prefix: SLOG", "# Логирование", "Как приложение пишет записи."} {
|
|
if !strings.Contains(body, want) {
|
|
t.Errorf("the file lost %q:\n%s", want, body)
|
|
}
|
|
}
|
|
if !strings.Contains(read(t, root, "suite.toml"), `logging = "логирование: уровни"`) {
|
|
t.Error("the topic did not reach the manifest")
|
|
}
|
|
checkClean(t, root)
|
|
}
|
|
|
|
// A known topic is described already, so the dialogue skips that question.
|
|
func TestInteractiveSkipsTheQuestionAboutAKnownTopic(t *testing.T) {
|
|
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", "Время")
|
|
|
|
answers := "time\nGTIM\nВремя на Go\ngo\n\n\n\n"
|
|
code, out := run(t, root, answers, true, "suite", "add")
|
|
if code != cli.OK {
|
|
t.Fatalf("the dialogue returned %d: %s", code, out)
|
|
}
|
|
if strings.Contains(out, "One line about the topic") {
|
|
t.Errorf("a known topic was asked about again:\n%s", out)
|
|
}
|
|
if !strings.Contains(out, "the topic is known") {
|
|
t.Errorf("the dialogue did not say the topic is known:\n%s", out)
|
|
}
|
|
checkClean(t, root)
|
|
}
|
|
|
|
// Without a terminal a bare command must refuse rather than block on an answer
|
|
// nobody is there to give.
|
|
func TestBareCommandRefusesWithoutATerminal(t *testing.T) {
|
|
root := filepath.Join(t.TempDir(), "suite")
|
|
run(t, ".", "", false, "suite", "init", "--path", root, "--lang", "ru")
|
|
|
|
code, out := run(t, root, "", false, "suite", "add")
|
|
if code != cli.Usage {
|
|
t.Fatalf("expected a refusal, got %d: %s", code, out)
|
|
}
|
|
if !strings.Contains(out, "no terminal") {
|
|
t.Errorf("the refusal does not say why:\n%s", out)
|
|
}
|
|
}
|
|
|
|
// Automatic mode names every missing field at once: being sent back one flag at
|
|
// a time is the worst way to learn what a command wants.
|
|
func TestAutomaticModeNamesEveryMissingField(t *testing.T) {
|
|
root := filepath.Join(t.TempDir(), "suite")
|
|
run(t, ".", "", false, "suite", "init", "--path", root, "--lang", "ru")
|
|
|
|
code, out := run(t, root, "", false, "suite", "add", "--topic", "time")
|
|
if code != cli.Usage {
|
|
t.Fatalf("expected a refusal, got %d: %s", code, out)
|
|
}
|
|
for _, want := range []string{"--prefix", "--title"} {
|
|
if !strings.Contains(out, want) {
|
|
t.Errorf("the refusal does not name %s:\n%s", want, out)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAddRefusesWhatTheSuiteAlreadyHolds(t *testing.T) {
|
|
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", "Время")
|
|
|
|
cases := []struct {
|
|
name string
|
|
args []string
|
|
want string
|
|
}{{
|
|
name: "a prefix already taken",
|
|
args: []string{"--topic", "config", "--about", "конфигурация", "--prefix", "TIME", "--title", "Конфигурация"},
|
|
want: "already taken",
|
|
}, {
|
|
name: "a prefix on the letter reserved for consumers",
|
|
args: []string{"--topic", "config", "--about", "конфигурация", "--prefix", "XCFG", "--title", "Конфигурация"},
|
|
want: "reserved for the local rules of consumers",
|
|
}, {
|
|
name: "a second base layer of one topic",
|
|
args: []string{"--topic", "time", "--prefix", "TIMB", "--title", "Время снова", "--path", "conventions/time-again.md"},
|
|
want: "already holds a base layer",
|
|
}, {
|
|
name: "a topic new to the suite and undescribed",
|
|
args: []string{"--topic", "config", "--prefix", "CONF", "--title", "Конфигурация"},
|
|
want: "--about is required",
|
|
}}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
code, out := run(t, root, "", false, append([]string{"suite", "add"}, 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 TestInitRefusesInsideASuite(t *testing.T) {
|
|
root := filepath.Join(t.TempDir(), "suite")
|
|
run(t, ".", "", false, "suite", "init", "--path", root, "--lang", "ru")
|
|
|
|
code, out := run(t, ".", "", false, "suite", "init", "--path", root)
|
|
if code != cli.Usage {
|
|
t.Fatalf("expected a refusal, got %d: %s", code, out)
|
|
}
|
|
if !strings.Contains(out, "this is a suite already") {
|
|
t.Errorf("the refusal does not say why:\n%s", out)
|
|
}
|
|
}
|
|
|
|
func TestInitRefusesAnUnknownVocabulary(t *testing.T) {
|
|
root := filepath.Join(t.TempDir(), "suite")
|
|
code, out := run(t, ".", "", false, "suite", "init", "--path", root, "--lang", "xx")
|
|
if code != cli.Usage {
|
|
t.Fatalf("expected a refusal, got %d: %s", code, out)
|
|
}
|
|
if !strings.Contains(out, "unknown to the tool") {
|
|
t.Errorf("the refusal does not say why:\n%s", out)
|
|
}
|
|
}
|