Files
convy/internal/cli/sync_test.go
T
av 600aba5ee3 закрыты известные остатки
- документ самоуправления объявляется ключом governance, а не угадывается
  по «он один и без ключей оси»: конвенция, потерявшая topic, была от него
  неотличима и тихо теряла все проверки об отъезде к потребителю
- проверка путей канона больше не ловит README.md и READING.md — эти два
  имени значат что-то и на стороне потребителя
- lang.Recognize требует совпадения и слов, и номера версии; директории
  компонентов сверяются на вложенность, а не только на равенство
- у обеих проверок появился --json, а convy sync называет ссылки на темы,
  которых компонент не взял
2026-07-28 10:16:27 +03:00

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"])
}
}