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. **ПОЧЕМУ.** Без явного смещения не видно, в какой зоне запись сделана. ` // 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{ "suite.toml": baseManifest, "LANGUAGE.md": "# Язык конвенций\n\nОписание языка.\n", "READING.md": "# Как читать конвенцию\n\nКоротко.\n", "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["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["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["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["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["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["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["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["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 second document without a topic", setup: func(f files) { f["suite.toml"] = strings.Replace(baseManifest, `TIME = "conventions/time.md"`, `TIME = "conventions/time.md"`+"\nMETA = \"GUIDE.md\"\nRULE = \"conventions/rules.md\"", 1) 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: "more than one document without a topic", }, { name: "document without a topic carries layer keys", setup: func(f files) { f["suite.toml"] = strings.Replace(baseManifest, `TIME = "conventions/time.md"`, `TIME = "conventions/time.md"`+"\nGTIM = \"conventions/go.md\"", 1) f["conventions/go.md"] = "---\nprefix: GTIM\nlang: go\n---\n\n# Go\n\n" + versionLine + "\n" }, want: "carries the keys of a layer", }} 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) } }) } }