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, ".conventions-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) } }