Files
convy/internal/check/check_test.go
T
av ccf046fb7b suite check: реализована проверка целостности набора
- разбор документа по языку конвенций: шапка, области правил, блоки под метками
- проверки формы правила, распространения, ссылок и самого suite.toml
- словарь языка живёт в бинаре реестром «версия × естественный язык», темы и
  префиксы берутся только из манифеста
2026-07-27 09:53:07 +03:00

318 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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))
}
}
// 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)
}
})
}
}