- разбор документа по языку конвенций: шапка, области правил, блоки под метками - проверки формы правила, распространения, ссылок и самого suite.toml - словарь языка живёт в бинаре реестром «версия × естественный язык», темы и префиксы берутся только из манифеста
318 lines
13 KiB
Go
318 lines
13 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"
|
||
)
|
||
|
||
// 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)
|
||
}
|
||
})
|
||
}
|
||
}
|