package check_test import ( "os" "path/filepath" "strings" "testing" "git.vakhrushev.me/av/convy/internal/check" "git.vakhrushev.me/av/convy/internal/suite" ) // versionLine — строка о версии языка. Она перечисляет ключевые слова набора, // поэтому единственная законно несёт модальные слова вне правил. 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 — содержимое набора: путь от корня к тексту файла. Пустая строка // значит «файла нет»: так тест снимает файл, который есть в основе. 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 записывает набор во временную директорию и прогоняет проверки. 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("загрузка набора: %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("исправный набор дал находки:\n%s", messages(got)) } } // TestRuleHeadingIsNotAReference держит границу между объявлением и ссылкой. // Заголовок с чужим префиксом — ошибка формы, и только она: разрешать заголовок // по манифесту не к чему, иначе одна опечатка даёт две находки о разном. 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("заголовок разобран как ссылка: %s", got.Msg) } } } // rule собирает правило целиком, чтобы тесты не повторяли его форму. 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: "префикс в шапке расходится с манифестом", setup: func(f files) { f["conventions/time.md"] = strings.Replace(baseTime, "prefix: TIME", "prefix: GTIM", 1) }, want: "префикс в шапке — GTIM, а манифест объявляет за этим файлом TIME", }, { name: "заголовок правила несёт чужой префикс", setup: func(f files) { f["conventions/time.md"] = strings.Replace(baseTime, "### TIME-1.", "### GTIM-1.", 1) }, want: "использует префикс GTIM, а файлу принадлежит TIME", }, { name: "нумерация с дырой", setup: func(f files) { f["conventions/time.md"] = baseTime + rule("TIME-3", "Третье", "Норма.", "Причина.") }, want: "нумерация не сплошная", }, { name: "номер занят дважды", setup: func(f files) { f["conventions/time.md"] = baseTime + rule("TIME-1", "Ещё раз первое", "Норма.", "Причина.") }, want: "номер TIME-1 занят дважды", }, { name: "правило без обоснования", setup: func(f files) { f["conventions/time.md"] = strings.Replace(baseTime, "**ПОЧЕМУ.** Без явного смещения не видно, в какой зоне запись сделана.", "", 1) }, want: "нет блока ПОЧЕМУ: обоснование обязательно", }, { name: "правило без нормы и без заглушки", setup: func(f files) { f["conventions/time.md"] = strings.Replace(baseTime, "**ДОЛЖЕН.** Момент времени записывается с суффиксом Z.", "Просто текст.", 1) }, want: "нет ни блока нормы, ни заглушки СНЯТО", }, { name: "две нормы под одним номером", setup: func(f files) { f["conventions/time.md"] = strings.Replace(baseTime, "**ПОЧЕМУ.**", "**СЛЕДУЕТ.** Вторая норма.\n\n**ПОЧЕМУ.**", 1) }, want: "две нормы (ДОЛЖЕН и СЛЕДУЕТ)", }, { name: "обоснование стоит раньше нормы", setup: func(f files) { f["conventions/time.md"] = strings.Replace(baseTime, "**ДОЛЖЕН.** Момент времени записывается с суффиксом Z.\n\n**ПОЧЕМУ.** Без явного смещения не видно, в какой зоне запись сделана.", "**ПОЧЕМУ.** Причина вперёд.\n\n**ДОЛЖЕН.** Момент времени записывается с суффиксом Z.", 1) }, want: "обоснование стоит раньше нормы", }, { name: "примеры стоят раньше обоснования", setup: func(f files) { f["conventions/time.md"] = strings.Replace(baseTime, "**ПОЧЕМУ.**", "**ПРИМЕРЫ.** Иллюстрация.\n\n**ПОЧЕМУ.**", 1) }, want: "блок ПРИМЕРЫ стоит раньше обоснования", }, { name: "заглушка снятого без даты", setup: func(f files) { f["conventions/time.md"] = strings.Replace(baseTime, "**ДОЛЖЕН.** Момент времени записывается с суффиксом Z.\n\n**ПОЧЕМУ.** Без явного смещения не видно, в какой зоне запись сделана.", "**СНЯТО.** Правило убрано за ненадобностью.", 1) }, want: "не несёт даты снятия", }, { name: "у снятого правила осталась норма", setup: func(f files) { f["conventions/time.md"] = baseTime + "\n### TIME-2. Снятое\n\n**СНЯТО 2026-07-26.** Причина снятия.\n\n**ДОЛЖЕН.** Остаток нормы.\n" }, want: "остался блок нормы", }, { name: "нет строки о версии языка", setup: func(f files) { f["conventions/time.md"] = strings.Replace(baseTime, versionLine, "Просто вводная проза.", 1) }, want: "нет строки о версии языка", }, { name: "строка о версии называет чужую версию", setup: func(f files) { f["conventions/time.md"] = strings.Replace(baseTime, "конвенций версии 1", "конвенций версии 2", 1) }, want: "не называет версию 1", }, { name: "модальное слово вне области правила", setup: func(f files) { f["conventions/time.md"] = strings.Replace(baseTime, "Как приложение записывает моменты.", "Приложение ДОЛЖЕН писать моменты.", 1) }, want: "стоит вне области правила", }, { name: "слово чужого словаря", setup: func(f files) { f["conventions/time.md"] = strings.Replace(baseTime, "**ПОЧЕМУ.** Без явного", "**ПОЧЕМУ.** Здесь MUST не к месту. Без явного", 1) }, want: `слово MUST принадлежит словарю "en"`, }, { name: "ссылка на несуществующее правило", setup: func(f files) { f["conventions/time.md"] = strings.Replace(baseTime, "Без явного смещения", "Смотри TIME-9. Без явного смещения", 1) }, want: "ссылка TIME-9 не разрешается", }, { name: "ссылка на неизвестный префикс", setup: func(f files) { f["conventions/time.md"] = strings.Replace(baseTime, "Без явного смещения", "Смотри ZZZZ-1. Без явного смещения", 1) }, want: "префикс ZZZZ, которого в манифесте набора нет", }, { name: "тема не объявлена в манифесте", setup: func(f files) { f["conventions/time.md"] = strings.Replace(baseTime, "topic: time", "topic: clocks", 1) }, want: `тема "clocks" не объявлена`, }, { name: "тема значится среди выбывших", setup: func(f files) { f["suite.toml"] = strings.Replace(baseManifest, "[topics.retired]", `[topics.retired]`+"\ntime = \"снята 2026-07-01\"", 1) }, want: "значится и среди живых, и среди выбывших", }, { name: "у живой темы нет слоёв", setup: func(f files) { f["suite.toml"] = strings.Replace(baseManifest, `time = "время: хранение, зоны, форматы"`, `time = "время"`+"\nlogging = \"логирование\"", 1) }, want: `тема "logging" объявлена живой, а слоёв у неё в наборе нет`, }, { name: "объявленного файла нет", setup: func(f files) { f["suite.toml"] = strings.Replace(baseManifest, `TIME = "conventions/time.md"`, `TIME = "conventions/time.md"`+"\nSLOG = \"conventions/logging.md\"", 1) }, want: `префикс SLOG объявлен за файлом "conventions/logging.md", а файла в наборе нет`, }, { name: "файл не зарегистрирован в манифесте", setup: func(f files) { f["conventions/logging.md"] = "---\ntopic: logging\nprefix: SLOG\n---\n\n# Логирование\n" }, want: "в манифесте набора не объявлен", }, { name: "префикс начинается на 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: "зарезервирована за локальными правилами потребителей", }, { name: "на один файл объявлено два префикса", setup: func(f files) { f["suite.toml"] = strings.Replace(baseManifest, `TIME = "conventions/time.md"`, `TIME = "conventions/time.md"`+"\nGTIM = \"conventions/time.md\"", 1) }, want: "объявлено несколько префиксов", }, { name: "ключ манифеста неизвестен", setup: func(f files) { f["suite.toml"] = baseManifest + "\n[extra]\nkey = 1\n" }, want: "инструменту неизвестен", }, { name: "метка механизации в тексте конвенции", setup: func(f files) { f["conventions/time.md"] = strings.Replace(baseTime, "Без явного смещения", "Правило МЕХАНИЗИРОВАНО линтером. Без явного смещения", 1) }, want: "её место — запись о механизации в локальной части копии", }, { name: "путь канона в тексте конвенции", setup: func(f files) { f["conventions/time.md"] = strings.Replace(baseTime, "Без явного смещения", "Смотри conventions/time.md. Без явного смещения", 1) }, want: "стоит путь файла канона", }} 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("проверка не сработала\nждали: %s\nполучили:\n%s", tc.want, got) } }) } }