- документ самоуправления объявляется ключом governance, а не угадывается по «он один и без ключей оси»: конвенция, потерявшая topic, была от него неотличима и тихо теряла все проверки об отъезде к потребителю - проверка путей канона больше не ловит README.md и READING.md — эти два имени значат что-то и на стороне потребителя - lang.Recognize требует совпадения и слов, и номера версии; директории компонентов сверяются на вложенность, а не только на равенство - у обеих проверок появился --json, а convy sync называет ссылки на темы, которых компонент не взял
272 lines
8.7 KiB
Go
272 lines
8.7 KiB
Go
package cli_test
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.vakhrushev.me/av/convy/internal/cli"
|
|
"git.vakhrushev.me/av/convy/internal/manifest"
|
|
)
|
|
|
|
// subscribe edits the project manifest the way a person would: by hand, in the
|
|
// file. That is the whole premise of sync — the manifest is the truth, and the
|
|
// layout follows it.
|
|
func subscribe(t *testing.T, root string, topics ...string) {
|
|
t.Helper()
|
|
m, err := manifest.LoadProject(root)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
c := m.Components["backend"]
|
|
c.Topics = topics
|
|
m.Components["backend"] = c
|
|
if err := m.Save(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestSyncAssemblesWhatIsMissingAndRemovesWhatIsOrphaned(t *testing.T) {
|
|
suiteRoot := subscribable(t)
|
|
root := wired(t, suiteRoot, "--component", "backend", "--dir", "docs/conventions", "--lang", "go")
|
|
run(t, root, "", false, "add", "time")
|
|
|
|
// The manifest is edited by hand: time goes, logging comes.
|
|
subscribe(t, root, "logging")
|
|
|
|
code, out := run(t, root, "", false, "sync", "--dry-run")
|
|
if code != cli.OK {
|
|
t.Fatalf("the dry run returned %d: %s", code, out)
|
|
}
|
|
if !strings.Contains(out, "would change") {
|
|
t.Errorf("the dry run promised nothing:\n%s", out)
|
|
}
|
|
if !exists(t, root, "docs/conventions/time.md") {
|
|
t.Errorf("the dry run removed a file")
|
|
}
|
|
if exists(t, root, "docs/conventions/logging.md") {
|
|
t.Errorf("the dry run assembled a file")
|
|
}
|
|
|
|
code, out = run(t, root, "", false, "sync")
|
|
if code != cli.OK {
|
|
t.Fatalf("sync returned %d: %s", code, out)
|
|
}
|
|
if !exists(t, root, "docs/conventions/logging.md") {
|
|
t.Errorf("the subscribed topic was not assembled:\n%s", out)
|
|
}
|
|
if exists(t, root, "docs/conventions/time.md") {
|
|
t.Errorf("the copy nothing subscribes to stayed:\n%s", out)
|
|
}
|
|
|
|
// Run again: nothing left to do, and it says so.
|
|
code, out = run(t, root, "", false, "sync")
|
|
if code != cli.OK || !strings.Contains(out, "already follows the manifest") {
|
|
t.Errorf("a second sync found work to do:\n%s", out)
|
|
}
|
|
}
|
|
|
|
// Below the marker is the one thing in the directory that exists nowhere else.
|
|
func TestSyncLeavesAnOrphanCarryingALocalPart(t *testing.T) {
|
|
suiteRoot := subscribable(t)
|
|
root := wired(t, suiteRoot, "--component", "backend", "--dir", "docs/conventions", "--lang", "go")
|
|
run(t, root, "", false, "add", "time")
|
|
|
|
name := filepath.Join(root, "docs", "conventions", "time.md")
|
|
body := read(t, root, "docs/conventions/time.md")
|
|
body += "\nTIME-1 — МЕХАНИЗИРОВАНО: `internal/archrules`.\n"
|
|
if err := os.WriteFile(name, []byte(body), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
subscribe(t, root)
|
|
|
|
code, out := run(t, root, "", false, "sync")
|
|
if code == cli.OK {
|
|
t.Fatalf("an orphan with a local part went unremarked:\n%s", out)
|
|
}
|
|
if !strings.Contains(out, "local part") {
|
|
t.Errorf("the report does not say why the file was left:\n%s", out)
|
|
}
|
|
if !exists(t, root, "docs/conventions/time.md") {
|
|
t.Fatalf("the local part was destroyed:\n%s", out)
|
|
}
|
|
}
|
|
|
|
// The manifest is the truth, so a manifest that does not hold together stops
|
|
// the command before anything is written.
|
|
func TestSyncValidatesTheManifestBeforeTouchingAnything(t *testing.T) {
|
|
suiteRoot := subscribable(t)
|
|
root := wired(t, suiteRoot, "--component", "backend", "--dir", "docs/conventions", "--lang", "go")
|
|
|
|
cases := []struct {
|
|
name string
|
|
change func(*manifest.Project)
|
|
want string
|
|
}{{
|
|
name: "a topic the suite does not declare",
|
|
change: func(m *manifest.Project) { subscribeTo(m, "billing") },
|
|
want: "no such topic",
|
|
}, {
|
|
name: "the same topic twice",
|
|
change: func(m *manifest.Project) { subscribeTo(m, "time", "time") },
|
|
want: "twice",
|
|
}, {
|
|
name: "two languages in one component",
|
|
change: func(m *manifest.Project) {
|
|
c := m.Components["backend"]
|
|
c.Lang = []string{"go", "javascript"}
|
|
m.Components["backend"] = c
|
|
},
|
|
want: "declares two languages",
|
|
}, {
|
|
name: "a topic no layer of which fits",
|
|
change: func(m *manifest.Project) {
|
|
// web-ui lives on the htmx stack only, and this component is on
|
|
// no stack at all.
|
|
subscribeTo(m, "web-ui")
|
|
},
|
|
want: "no layer of it fits",
|
|
}}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
m, err := manifest.LoadProject(root)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
tc.change(m)
|
|
if err := m.Save(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
code, out := run(t, root, "", false, "sync")
|
|
if code == cli.OK {
|
|
t.Fatalf("the manifest went through:\n%s", out)
|
|
}
|
|
if !strings.Contains(out, tc.want) {
|
|
t.Errorf("the report does not say %q:\n%s", tc.want, out)
|
|
}
|
|
if !strings.Contains(out, "nothing was touched") {
|
|
t.Errorf("the report does not say it wrote nothing:\n%s", out)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func subscribeTo(m *manifest.Project, topics ...string) {
|
|
c := m.Components["backend"]
|
|
c.Topics = topics
|
|
m.Components["backend"] = c
|
|
}
|
|
|
|
func exists(t *testing.T, parts ...string) bool {
|
|
t.Helper()
|
|
_, err := os.Stat(filepath.Join(parts...))
|
|
return err == nil
|
|
}
|
|
|
|
// One directory inside another is the same collision told less plainly, and it
|
|
// makes every command that walks a component find the copies of the other.
|
|
func TestNestedComponentDirectoriesAreRefused(t *testing.T) {
|
|
suiteRoot := subscribable(t)
|
|
root := wired(t, suiteRoot, "--component", "backend", "--dir", "docs", "--lang", "go")
|
|
addComponent(t, root, "web", manifest.Component{Dir: "docs/web", Topics: []string{}})
|
|
|
|
for _, name := range []string{"sync", "pull", "check"} {
|
|
t.Run(name, func(t *testing.T) {
|
|
code, out := run(t, root, "", false, name)
|
|
if code == cli.OK {
|
|
t.Fatalf("nested directories went through:\n%s", out)
|
|
}
|
|
if !strings.Contains(out, "lies inside") {
|
|
t.Errorf("the refusal does not say what is nested:\n%s", out)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// A copy may name a rule of a topic the component did not take: naming one
|
|
// outside the norm is allowed, and the reader loses a pointer rather than a
|
|
// requirement. It is still the one thing about a copy that cannot be seen
|
|
// without the suite, so sync says it.
|
|
func TestSyncNotesAReferenceToAnUnsubscribedTopic(t *testing.T) {
|
|
suiteRoot := subscribable(t)
|
|
root := wired(t, suiteRoot, "--component", "backend", "--dir", "docs/conventions", "--lang", "go")
|
|
run(t, root, "", false, "add", "time")
|
|
|
|
name := filepath.Join(root, "docs", "conventions", "time.md")
|
|
body := read(t, root, "docs/conventions/time.md")
|
|
body += "\nПодробности — SLOG-1.\n"
|
|
if err := os.WriteFile(name, []byte(body), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
code, out := run(t, root, "", false, "sync")
|
|
if code != cli.OK {
|
|
t.Fatalf("sync returned %d: %s", code, out)
|
|
}
|
|
if !strings.Contains(out, "SLOG-1") || !strings.Contains(out, "logging") {
|
|
t.Errorf("the note does not name the reference and its topic:\n%s", out)
|
|
}
|
|
|
|
// Subscribed, it is no longer dangling.
|
|
run(t, root, "", false, "add", "logging")
|
|
_, out = run(t, root, "", false, "sync")
|
|
if strings.Contains(out, "not taken by this component") {
|
|
t.Errorf("the note stayed after the topic was taken:\n%s", out)
|
|
}
|
|
}
|
|
|
|
// The findings a person reads and the findings a machine reads are the same
|
|
// findings, in the same order.
|
|
func TestCheckWritesJSONForACallerThatIsNotAPerson(t *testing.T) {
|
|
suiteRoot := subscribable(t)
|
|
root := wired(t, suiteRoot, "--component", "backend", "--dir", "docs/conventions", "--lang", "go")
|
|
run(t, root, "", false, "add", "time")
|
|
|
|
code, out := run(t, root, "", false, "check", "--json")
|
|
if code != cli.OK {
|
|
t.Fatalf("convy check --json returned %d: %s", code, out)
|
|
}
|
|
var clean struct {
|
|
Findings []map[string]any `json:"findings"`
|
|
Errors int `json:"errors"`
|
|
Warnings int `json:"warnings"`
|
|
}
|
|
if err := json.Unmarshal([]byte(out), &clean); err != nil {
|
|
t.Fatalf("the output is not JSON: %v\n%s", err, out)
|
|
}
|
|
if len(clean.Findings) != 0 || clean.Errors != 0 {
|
|
t.Errorf("a sound project reported findings: %s", out)
|
|
}
|
|
|
|
// Now break it and read the finding back.
|
|
name := filepath.Join(root, "docs", "conventions", "time.md")
|
|
body := read(t, root, "docs/conventions/time.md")
|
|
body = strings.Replace(body, "<!-- conv:local -->", "", 1)
|
|
if err := os.WriteFile(name, []byte(body), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
code, out = run(t, root, "", false, "check", "--json")
|
|
if code == cli.OK {
|
|
t.Fatalf("the broken copy passed:\n%s", out)
|
|
}
|
|
if err := json.Unmarshal([]byte(out), &clean); err != nil {
|
|
t.Fatalf("the output is not JSON: %v\n%s", err, out)
|
|
}
|
|
if clean.Errors != 1 || len(clean.Findings) != 1 {
|
|
t.Fatalf("expected one error, got %s", out)
|
|
}
|
|
f := clean.Findings[0]
|
|
for key, want := range map[string]any{"severity": "error", "family": "spread"} {
|
|
if f[key] != want {
|
|
t.Errorf("%s is %v, expected %v", key, f[key], want)
|
|
}
|
|
}
|
|
if f["path"] != "docs/conventions/time.md" {
|
|
t.Errorf("path is %v", f["path"])
|
|
}
|
|
}
|