- документ самоуправления объявляется ключом governance, а не угадывается по «он один и без ключей оси»: конвенция, потерявшая topic, была от него неотличима и тихо теряла все проверки об отъезде к потребителю - проверка путей канона больше не ловит README.md и READING.md — эти два имени значат что-то и на стороне потребителя - lang.Recognize требует совпадения и слов, и номера версии; директории компонентов сверяются на вложенность, а не только на равенство - у обеих проверок появился --json, а convy sync называет ссылки на темы, которых компонент не взял
462 lines
17 KiB
Go
462 lines
17 KiB
Go
package check_test
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.vakhrushev.me/av/convy/internal/check"
|
|
"git.vakhrushev.me/av/convy/internal/suite"
|
|
)
|
|
|
|
// The fixtures below stay in Russian on purpose: they are the data under test,
|
|
// written in the natural language the suite declares. Only the tool's own text
|
|
// is English.
|
|
|
|
// versionLine is the language version line. It lists the key words of the
|
|
// suite, which makes it the only place lawfully carrying modal words outside a
|
|
// rule.
|
|
const versionLine = `Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки
|
|
ПОЧЕМУ, ПРИМЕРЫ, МЕХАНИЗИРОВАНО и СНЯТО толкуются как описано в языке
|
|
конвенций версии 1 — тогда и только тогда, когда написаны заглавными.`
|
|
|
|
const baseManifest = `
|
|
[language]
|
|
version = 1
|
|
description = "LANGUAGE.md"
|
|
reading = "READING.md"
|
|
|
|
[topics.live]
|
|
time = "время: хранение, зоны, форматы"
|
|
|
|
[topics.retired]
|
|
|
|
[prefixes.live]
|
|
TIME = "conventions/time.md"
|
|
|
|
[prefixes.retired]
|
|
`
|
|
|
|
const baseTime = `---
|
|
topic: time
|
|
prefix: TIME
|
|
---
|
|
|
|
# Время
|
|
|
|
Как приложение записывает моменты.
|
|
|
|
` + versionLine + `
|
|
|
|
## Правила
|
|
|
|
### TIME-1. Момент записывается в UTC
|
|
|
|
**ДОЛЖЕН.** Момент времени записывается с суффиксом Z.
|
|
|
|
**ПОЧЕМУ.** Без явного смещения не видно, в какой зоне запись сделана.
|
|
`
|
|
|
|
// reading stands for the short account of the language, the one document about
|
|
// it that travels into a copy. It has to name every word of the vocabulary:
|
|
// that is the whole of META-30.
|
|
const reading = `# Как читать конвенцию
|
|
|
|
Ключевые слова: ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ.
|
|
Метки: ПОЧЕМУ, ПРИМЕРЫ, МЕХАНИЗИРОВАНО, СНЯТО.
|
|
`
|
|
|
|
// files is the content of a suite: a path from the root mapped to the text of
|
|
// the file. An empty string means "no such file": that is how a test drops a
|
|
// file the base fixture provides.
|
|
type files map[string]string
|
|
|
|
func base() files {
|
|
return files{
|
|
".conventions-suite.toml": baseManifest,
|
|
"LANGUAGE.md": "# Язык конвенций\n\nОписание языка.\n",
|
|
"READING.md": reading,
|
|
"conventions/time.md": baseTime,
|
|
}
|
|
}
|
|
|
|
// run writes a suite into a temporary directory and runs the checks over it.
|
|
func run(t *testing.T, f files) []check.Finding {
|
|
t.Helper()
|
|
root := t.TempDir()
|
|
for name, content := range f {
|
|
if content == "" {
|
|
continue
|
|
}
|
|
path := filepath.Join(root, filepath.FromSlash(name))
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
s, err := suite.Load(root)
|
|
if err != nil {
|
|
t.Fatalf("loading the suite: %v", err)
|
|
}
|
|
return check.Suite(s).Findings()
|
|
}
|
|
|
|
func messages(findings []check.Finding) string {
|
|
var b strings.Builder
|
|
for _, f := range findings {
|
|
b.WriteString(f.Path)
|
|
b.WriteString(": ")
|
|
b.WriteString(f.Msg)
|
|
b.WriteString("\n")
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func TestCleanSuite(t *testing.T) {
|
|
if got := run(t, base()); len(got) != 0 {
|
|
t.Fatalf("a sound suite produced findings:\n%s", messages(got))
|
|
}
|
|
}
|
|
|
|
// TestRuleHeadingIsNotAReference holds the line between a declaration and a
|
|
// reference. A heading with a foreign prefix is an error of form, and only
|
|
// that: there is nothing to resolve against the manifest, otherwise one typo
|
|
// would yield two findings about different things.
|
|
func TestRuleHeadingIsNotAReference(t *testing.T) {
|
|
f := base()
|
|
f["conventions/time.md"] = strings.Replace(baseTime, "### TIME-1.", "### GTIM-1.", 1)
|
|
|
|
for _, got := range run(t, f) {
|
|
if got.Family == check.Links {
|
|
t.Errorf("a heading was parsed as a reference: %s", got.Msg)
|
|
}
|
|
}
|
|
}
|
|
|
|
// rule builds a whole rule so that tests do not repeat its form.
|
|
func rule(id, title, norm, rationale string) string {
|
|
return "\n### " + id + ". " + title + "\n\n**ДОЛЖЕН.** " + norm + "\n\n**ПОЧЕМУ.** " + rationale + "\n"
|
|
}
|
|
|
|
func TestChecks(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
setup func(files)
|
|
want string
|
|
}{{
|
|
name: "front matter prefix diverges from the manifest",
|
|
setup: func(f files) {
|
|
f["conventions/time.md"] = strings.Replace(baseTime, "prefix: TIME", "prefix: GTIM", 1)
|
|
},
|
|
want: "the front matter declares prefix GTIM, while the manifest assigns TIME to this file",
|
|
}, {
|
|
name: "rule heading carries a foreign prefix",
|
|
setup: func(f files) {
|
|
f["conventions/time.md"] = strings.Replace(baseTime, "### TIME-1.", "### GTIM-1.", 1)
|
|
},
|
|
want: "the rule heading uses prefix GTIM, while the file owns TIME",
|
|
}, {
|
|
name: "numbering has a gap",
|
|
setup: func(f files) {
|
|
f["conventions/time.md"] = baseTime + rule("TIME-3", "Третье", "Норма.", "Причина.")
|
|
},
|
|
want: "numbering is not contiguous",
|
|
}, {
|
|
name: "a number is taken twice",
|
|
setup: func(f files) {
|
|
f["conventions/time.md"] = baseTime + rule("TIME-1", "Ещё раз первое", "Норма.", "Причина.")
|
|
},
|
|
want: "number TIME-1 is taken twice",
|
|
}, {
|
|
name: "rule without a rationale",
|
|
setup: func(f files) {
|
|
f["conventions/time.md"] = strings.Replace(baseTime,
|
|
"**ПОЧЕМУ.** Без явного смещения не видно, в какой зоне запись сделана.", "", 1)
|
|
},
|
|
want: "has no ПОЧЕМУ block: the rationale is mandatory",
|
|
}, {
|
|
name: "rule with neither a norm nor a stub",
|
|
setup: func(f files) {
|
|
f["conventions/time.md"] = strings.Replace(baseTime,
|
|
"**ДОЛЖЕН.** Момент времени записывается с суффиксом Z.", "Просто текст.", 1)
|
|
},
|
|
want: "has neither a norm block nor a СНЯТО stub",
|
|
}, {
|
|
name: "two norms under one number",
|
|
setup: func(f files) {
|
|
f["conventions/time.md"] = strings.Replace(baseTime,
|
|
"**ПОЧЕМУ.**", "**СЛЕДУЕТ.** Вторая норма.\n\n**ПОЧЕМУ.**", 1)
|
|
},
|
|
want: "holds two norms (ДОЛЖЕН and СЛЕДУЕТ)",
|
|
}, {
|
|
name: "rationale precedes the norm",
|
|
setup: func(f files) {
|
|
f["conventions/time.md"] = strings.Replace(baseTime,
|
|
"**ДОЛЖЕН.** Момент времени записывается с суффиксом Z.\n\n**ПОЧЕМУ.** Без явного смещения не видно, в какой зоне запись сделана.",
|
|
"**ПОЧЕМУ.** Причина вперёд.\n\n**ДОЛЖЕН.** Момент времени записывается с суффиксом Z.", 1)
|
|
},
|
|
want: "the rationale precedes the norm",
|
|
}, {
|
|
name: "examples precede the rationale",
|
|
setup: func(f files) {
|
|
f["conventions/time.md"] = strings.Replace(baseTime,
|
|
"**ПОЧЕМУ.**", "**ПРИМЕРЫ.** Иллюстрация.\n\n**ПОЧЕМУ.**", 1)
|
|
},
|
|
want: "the ПРИМЕРЫ block precedes the rationale",
|
|
}, {
|
|
name: "stub of a retired rule without a date",
|
|
setup: func(f files) {
|
|
f["conventions/time.md"] = strings.Replace(baseTime,
|
|
"**ДОЛЖЕН.** Момент времени записывается с суффиксом Z.\n\n**ПОЧЕМУ.** Без явного смещения не видно, в какой зоне запись сделана.",
|
|
"**СНЯТО.** Правило убрано за ненадобностью.", 1)
|
|
},
|
|
want: "carries no date of retirement",
|
|
}, {
|
|
name: "retired rule still holds a norm",
|
|
setup: func(f files) {
|
|
f["conventions/time.md"] = baseTime +
|
|
"\n### TIME-2. Снятое\n\n**СНЯТО 2026-07-26.** Причина снятия.\n\n**ДОЛЖЕН.** Остаток нормы.\n"
|
|
},
|
|
want: "still holds a norm block",
|
|
}, {
|
|
name: "no language version line",
|
|
setup: func(f files) {
|
|
f["conventions/time.md"] = strings.Replace(baseTime, versionLine, "Просто вводная проза.", 1)
|
|
},
|
|
want: "holds no language version line",
|
|
}, {
|
|
name: "version line names a foreign version",
|
|
setup: func(f files) {
|
|
f["conventions/time.md"] = strings.Replace(baseTime, "конвенций версии 1", "конвенций версии 2", 1)
|
|
},
|
|
want: "does not name version 1",
|
|
}, {
|
|
name: "modal word outside a rule area",
|
|
setup: func(f files) {
|
|
f["conventions/time.md"] = strings.Replace(baseTime,
|
|
"Как приложение записывает моменты.", "Приложение ДОЛЖЕН писать моменты.", 1)
|
|
},
|
|
want: "stands outside a rule area",
|
|
}, {
|
|
name: "word of a foreign vocabulary",
|
|
setup: func(f files) {
|
|
f["conventions/time.md"] = strings.Replace(baseTime,
|
|
"**ПОЧЕМУ.** Без явного", "**ПОЧЕМУ.** Здесь MUST не к месту. Без явного", 1)
|
|
},
|
|
want: `the word MUST belongs to the "en" vocabulary`,
|
|
}, {
|
|
name: "reference to a rule that does not exist",
|
|
setup: func(f files) {
|
|
f["conventions/time.md"] = strings.Replace(baseTime,
|
|
"Без явного смещения", "Смотри TIME-9. Без явного смещения", 1)
|
|
},
|
|
want: "reference TIME-9 does not resolve",
|
|
}, {
|
|
name: "reference to an unknown prefix",
|
|
setup: func(f files) {
|
|
f["conventions/time.md"] = strings.Replace(baseTime,
|
|
"Без явного смещения", "Смотри ZZZZ-1. Без явного смещения", 1)
|
|
},
|
|
want: "prefix ZZZZ, which the suite manifest does not declare",
|
|
}, {
|
|
name: "topic not declared in the manifest",
|
|
setup: func(f files) {
|
|
f["conventions/time.md"] = strings.Replace(baseTime, "topic: time", "topic: clocks", 1)
|
|
},
|
|
want: `topic "clocks" is not declared`,
|
|
}, {
|
|
name: "topic listed among the retired ones",
|
|
setup: func(f files) {
|
|
f[".conventions-suite.toml"] = strings.Replace(baseManifest,
|
|
"[topics.retired]", `[topics.retired]`+"\ntime = \"снята 2026-07-01\"", 1)
|
|
},
|
|
want: "is listed both live and retired",
|
|
}, {
|
|
name: "live topic without layers",
|
|
setup: func(f files) {
|
|
f[".conventions-suite.toml"] = strings.Replace(baseManifest,
|
|
`time = "время: хранение, зоны, форматы"`,
|
|
`time = "время"`+"\nlogging = \"логирование\"", 1)
|
|
},
|
|
want: `topic "logging" is declared live while the suite holds no layer of it`,
|
|
}, {
|
|
name: "declared file is missing",
|
|
setup: func(f files) {
|
|
f[".conventions-suite.toml"] = strings.Replace(baseManifest,
|
|
`TIME = "conventions/time.md"`,
|
|
`TIME = "conventions/time.md"`+"\nSLOG = \"conventions/logging.md\"", 1)
|
|
},
|
|
want: `prefix SLOG is assigned to the file "conventions/logging.md", which the suite does not hold`,
|
|
}, {
|
|
name: "file not registered in the manifest",
|
|
setup: func(f files) {
|
|
f["conventions/logging.md"] = "---\ntopic: logging\nprefix: SLOG\n---\n\n# Логирование\n"
|
|
},
|
|
want: "not declared in the suite manifest",
|
|
}, {
|
|
name: "prefix starts with X",
|
|
setup: func(f files) {
|
|
f[".conventions-suite.toml"] = strings.Replace(baseManifest,
|
|
`TIME = "conventions/time.md"`, `XTIM = "conventions/time.md"`, 1)
|
|
f["conventions/time.md"] = strings.ReplaceAll(baseTime, "TIME", "XTIM")
|
|
},
|
|
want: "reserved for the local rules of consumers",
|
|
}, {
|
|
name: "one file with two prefixes declared",
|
|
setup: func(f files) {
|
|
f[".conventions-suite.toml"] = strings.Replace(baseManifest,
|
|
`TIME = "conventions/time.md"`,
|
|
`TIME = "conventions/time.md"`+"\nGTIM = \"conventions/time.md\"", 1)
|
|
},
|
|
want: "has several prefixes declared for it",
|
|
}, {
|
|
name: "unknown manifest key",
|
|
setup: func(f files) {
|
|
f[".conventions-suite.toml"] = baseManifest + "\n[extra]\nkey = 1\n"
|
|
},
|
|
want: "is unknown to the tool",
|
|
}, {
|
|
name: "mechanization mark in convention text",
|
|
setup: func(f files) {
|
|
f["conventions/time.md"] = strings.Replace(baseTime,
|
|
"Без явного смещения", "Правило МЕХАНИЗИРОВАНО линтером. Без явного смещения", 1)
|
|
},
|
|
want: "its place is the note of mechanization in the local part of the copy",
|
|
}, {
|
|
name: "canon path in convention text",
|
|
setup: func(f files) {
|
|
f["conventions/time.md"] = strings.Replace(baseTime,
|
|
"Без явного смещения", "Смотри conventions/time.md. Без явного смещения", 1)
|
|
},
|
|
want: "the text holds the canon file path",
|
|
}, {
|
|
name: "document opens below level one",
|
|
setup: func(f files) {
|
|
f["conventions/time.md"] = strings.Replace(baseTime, "# Время", "## Время", 1)
|
|
},
|
|
want: "opens with a level-2 heading",
|
|
}, {
|
|
name: "second level-one heading",
|
|
setup: func(f files) {
|
|
f["conventions/time.md"] = baseTime + "\n# Ещё один заголовок\n\nПроза.\n"
|
|
},
|
|
want: "holds a second level-1 heading",
|
|
}, {
|
|
name: "heading skips a level",
|
|
setup: func(f files) {
|
|
f["conventions/time.md"] = strings.Replace(baseTime, "## Правила", "#### Правила", 1)
|
|
},
|
|
want: "skipping a level",
|
|
}, {
|
|
name: "scenario block opens with a foreign connective",
|
|
setup: func(f files) {
|
|
f["conventions/time.md"] = baseTime +
|
|
"\nWHEN зависимость недоступна\nТОГДА запись ERROR\n"
|
|
},
|
|
want: `the scenario block opens with WHEN from the "en" vocabulary`,
|
|
}, {
|
|
name: "topic name is not usable as a file name",
|
|
setup: func(f files) {
|
|
// A bare non-ASCII key TOML rejects on its own; a quoted one
|
|
// passes straight through, which is what the check is for.
|
|
f[".conventions-suite.toml"] = strings.Replace(baseManifest, "time =", `"время" =`, 1)
|
|
f["conventions/time.md"] = strings.Replace(baseTime, "topic: time", "topic: время", 1)
|
|
},
|
|
want: "is not usable as a file name",
|
|
}, {
|
|
name: "topic name is not lower kebab-case",
|
|
setup: func(f files) {
|
|
f[".conventions-suite.toml"] = strings.Replace(baseManifest, "time =", "Time_Zone =", 1)
|
|
f["conventions/time.md"] = strings.Replace(baseTime, "topic: time", "topic: Time_Zone", 1)
|
|
},
|
|
want: "is not lower kebab-case",
|
|
}, {
|
|
name: "a convention that lost its topic next to the governing document",
|
|
setup: func(f files) {
|
|
f[".conventions-suite.toml"] = strings.Replace(baseManifest,
|
|
`TIME = "conventions/time.md"`,
|
|
`TIME = "conventions/time.md"`+"\nMETA = \"GUIDE.md\"\nRULE = \"conventions/rules.md\"", 1)
|
|
f[".conventions-suite.toml"] = "governance = \"GUIDE.md\"\n" + f[".conventions-suite.toml"]
|
|
f["GUIDE.md"] = "---\nprefix: META\n---\n\n# Как мы ведём конвенции\n\n" + versionLine + "\n"
|
|
f["conventions/rules.md"] = "---\nprefix: RULE\n---\n\n# Правила\n\n" + versionLine + "\n"
|
|
},
|
|
want: "has lost the key",
|
|
}, {
|
|
name: "a convention that lost its topic in a suite with no governing document",
|
|
setup: func(f files) {
|
|
f["conventions/time.md"] = strings.Replace(baseTime, "topic: time\n", "", 1)
|
|
},
|
|
want: "the manifest names no document the suite governs itself by",
|
|
}, {
|
|
name: "the governing document declared with a topic",
|
|
setup: func(f files) {
|
|
f[".conventions-suite.toml"] = "governance = \"conventions/time.md\"\n" + baseManifest
|
|
},
|
|
want: "belongs to no topic",
|
|
}, {
|
|
name: "the short account of the language lost a word of the vocabulary",
|
|
setup: func(f files) {
|
|
f["READING.md"] = strings.Replace(reading, ", ДОПУСКАЕТСЯ", "", 1)
|
|
},
|
|
want: "does not name ДОПУСКАЕТСЯ",
|
|
}, {
|
|
name: "the governing document carries layer keys",
|
|
setup: func(f files) {
|
|
f[".conventions-suite.toml"] = strings.Replace(baseManifest,
|
|
`TIME = "conventions/time.md"`,
|
|
`TIME = "conventions/time.md"`+"\nGTIM = \"conventions/go.md\"", 1)
|
|
f[".conventions-suite.toml"] = "governance = \"conventions/go.md\"\n" + f[".conventions-suite.toml"]
|
|
f["conventions/go.md"] = "---\nprefix: GTIM\nlang: go\n---\n\n# Go\n\n" + versionLine + "\n"
|
|
},
|
|
want: "carries the keys of a layer",
|
|
}, {
|
|
name: "governance names a file that is not there",
|
|
setup: func(f files) {
|
|
f[".conventions-suite.toml"] = "governance = \"GUIDE.md\"\n" + baseManifest
|
|
},
|
|
want: "governance names",
|
|
}}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
f := base()
|
|
tc.setup(f)
|
|
got := messages(run(t, f))
|
|
if !strings.Contains(got, tc.want) {
|
|
t.Fatalf("the check did not fire\nwanted: %s\ngot:\n%s", tc.want, got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// The two names that mean something on the consumer's side as well: a
|
|
// convention naming either is talking about the copy, not about the suite.
|
|
func TestCanonPathLeavesTheConsumersOwnNames(t *testing.T) {
|
|
f := base()
|
|
f["README.md"] = "# Канон\n"
|
|
f["conventions/time.md"] = strings.Replace(baseTime, "## Правила",
|
|
"Таблица тем собирается в README.md директории конвенций, а как читать\nправило — сказано в READING.md рядом с копиями.\n\n## Правила", 1)
|
|
|
|
for _, finding := range run(t, f) {
|
|
if strings.Contains(finding.Msg, "canon file path") {
|
|
t.Errorf("a name of the consumer's own was taken for a path of the suite: %s", finding.Msg)
|
|
}
|
|
}
|
|
|
|
// A path of a convention is still a path of a convention.
|
|
f["conventions/time.md"] = strings.Replace(baseTime, "## Правила",
|
|
"Подробности — в conventions/time.md.\n\n## Правила", 1)
|
|
found := false
|
|
for _, finding := range run(t, f) {
|
|
if strings.Contains(finding.Msg, "canon file path") {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("a path of a convention went unnoticed:\n%s", messages(run(t, f)))
|
|
}
|
|
}
|