suite check: реализована проверка целостности набора
- разбор документа по языку конвенций: шапка, области правил, блоки под метками - проверки формы правила, распространения, ссылок и самого suite.toml - словарь языка живёт в бинаре реестром «версия × естественный язык», темы и префиксы берутся только из манифеста
This commit is contained in:
@@ -0,0 +1,236 @@
|
||||
package check_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Набор с двумя слоями одной темы: базовый арх-слой и языковой поверх него.
|
||||
// На нём проверяется всё, что про оси, extends и границу самодостаточности
|
||||
// нормы, — на одном слое эти проверки выразить нечем.
|
||||
|
||||
const layeredManifest = `
|
||||
[language]
|
||||
version = 1
|
||||
description = "LANGUAGE.md"
|
||||
reading = "READING.md"
|
||||
|
||||
[topics.live]
|
||||
time = "время"
|
||||
logging = "логирование"
|
||||
|
||||
[topics.retired]
|
||||
|
||||
[prefixes.live]
|
||||
TIME = "conventions/arch/time.md"
|
||||
GTIM = "conventions/lang/go/time.md"
|
||||
SLOG = "conventions/arch/logging.md"
|
||||
|
||||
[prefixes.retired]
|
||||
`
|
||||
|
||||
const archTime = `---
|
||||
topic: time
|
||||
prefix: TIME
|
||||
---
|
||||
|
||||
# Время
|
||||
|
||||
Как приложение записывает моменты.
|
||||
|
||||
` + versionLine + `
|
||||
|
||||
## Правила
|
||||
|
||||
### TIME-1. Момент записывается в UTC
|
||||
|
||||
**ДОЛЖЕН.** Момент времени записывается с суффиксом Z.
|
||||
|
||||
**ПОЧЕМУ.** Без явного смещения не видно, в какой зоне запись сделана.
|
||||
`
|
||||
|
||||
const goTime = `---
|
||||
topic: time
|
||||
prefix: GTIM
|
||||
lang: go
|
||||
extends: arch/time.md
|
||||
---
|
||||
|
||||
# Время: реализация на Go
|
||||
|
||||
Как требования базового слоя выполняются в Go-коде.
|
||||
|
||||
` + versionLine + `
|
||||
|
||||
## Правила
|
||||
|
||||
### GTIM-1. «Сейчас» берётся у слоя хранилища
|
||||
|
||||
**ДОЛЖЕН.** Текущее время приходит из store.Now().
|
||||
|
||||
**ПОЧЕМУ.** Единая точка даёт гарантированный UTC.
|
||||
`
|
||||
|
||||
const archLogging = `---
|
||||
topic: logging
|
||||
prefix: SLOG
|
||||
---
|
||||
|
||||
# Логирование
|
||||
|
||||
Как приложение пишет записи.
|
||||
|
||||
` + versionLine + `
|
||||
|
||||
## Правила
|
||||
|
||||
### SLOG-1. Уровень выбирается по адресату
|
||||
|
||||
**ДОЛЖЕН.** Уровень отвечает на вопрос «кому сообщение».
|
||||
|
||||
**ПОЧЕМУ.** Адресат — единственный воспроизводимый признак.
|
||||
`
|
||||
|
||||
func layered() files {
|
||||
return files{
|
||||
"suite.toml": layeredManifest,
|
||||
"LANGUAGE.md": "# Язык конвенций\n\nОписание языка.\n",
|
||||
"READING.md": "# Как читать конвенцию\n\nКоротко.\n",
|
||||
"conventions/arch/time.md": archTime,
|
||||
"conventions/lang/go/time.md": goTime,
|
||||
"conventions/arch/logging.md": archLogging,
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayeredSuiteIsClean(t *testing.T) {
|
||||
if got := run(t, layered()); len(got) != 0 {
|
||||
t.Fatalf("исправный многослойный набор дал находки:\n%s", messages(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayeredChecks(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
setup func(files)
|
||||
want string
|
||||
}{{
|
||||
name: "ось в шапке расходится с путём",
|
||||
setup: func(f files) {
|
||||
f["conventions/lang/go/time.md"] = strings.Replace(goTime, "lang: go", "lang: python", 1)
|
||||
},
|
||||
want: `путь кладёт файл на ось lang=go, а шапка объявляет lang="python"`,
|
||||
}, {
|
||||
name: "extends ведёт в чужую тему",
|
||||
setup: func(f files) {
|
||||
f["conventions/lang/go/time.md"] = strings.Replace(goTime,
|
||||
"extends: arch/time.md", "extends: arch/logging.md", 1)
|
||||
},
|
||||
want: `с темой "logging", а файл несёт тему "time"`,
|
||||
}, {
|
||||
name: "extends ведёт в несуществующий файл",
|
||||
setup: func(f files) {
|
||||
f["conventions/lang/go/time.md"] = strings.Replace(goTime,
|
||||
"extends: arch/time.md", "extends: arch/clocks.md", 1)
|
||||
},
|
||||
want: `а такого файла в наборе нет`,
|
||||
}, {
|
||||
name: "у темы два слоя без ключей оси",
|
||||
setup: func(f files) {
|
||||
f["suite.toml"] = strings.Replace(layeredManifest,
|
||||
`GTIM = "conventions/lang/go/time.md"`,
|
||||
`GTIM = "conventions/second/time.md"`, 1)
|
||||
f["conventions/lang/go/time.md"] = ""
|
||||
f["conventions/second/time.md"] = strings.Replace(
|
||||
strings.Replace(goTime, "lang: go\n", "", 1),
|
||||
"extends: arch/time.md\n", "", 1)
|
||||
},
|
||||
want: "больше одного слоя без ключей оси",
|
||||
}, {
|
||||
name: "норма ссылается на префикс чужой темы",
|
||||
setup: func(f files) {
|
||||
f["conventions/lang/go/time.md"] = strings.Replace(goTime,
|
||||
"**ДОЛЖЕН.** Текущее время приходит из store.Now().",
|
||||
"**ДОЛЖЕН.** Текущее время приходит из store.Now() и пишется по SLOG-1.", 1)
|
||||
},
|
||||
want: `ссылается на SLOG-1 из чужой темы "logging"`,
|
||||
}, {
|
||||
name: "норма ссылается на неба́зовый слой своей темы",
|
||||
setup: func(f files) {
|
||||
f["conventions/arch/time.md"] = strings.Replace(archTime,
|
||||
"**ДОЛЖЕН.** Момент времени записывается с суффиксом Z.",
|
||||
"**ДОЛЖЕН.** Момент времени записывается с суффиксом Z, как требует GTIM-1.", 1)
|
||||
},
|
||||
want: "слой своей темы, но не базовый",
|
||||
}}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
f := layered()
|
||||
tc.setup(f)
|
||||
got := messages(run(t, f))
|
||||
if !strings.Contains(got, tc.want) {
|
||||
t.Fatalf("проверка не сработала\nждали: %s\nполучили:\n%s", tc.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNoFalsePositives собирает случаи, в которых проверка обязана промолчать.
|
||||
// Ложное срабатывание здесь дороже пропуска: проверку, которая краснеет на
|
||||
// исправном файле, выключают целиком.
|
||||
func TestNoFalsePositives(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
setup func(files)
|
||||
}{{
|
||||
name: "идентификатор в бэктиках — образец записи, а не ссылка",
|
||||
setup: func(f files) {
|
||||
f["conventions/arch/time.md"] = strings.Replace(archTime,
|
||||
"Без явного смещения",
|
||||
"На правило ссылаются идентификатором (`TIME-99`). Без явного смещения", 1)
|
||||
},
|
||||
}, {
|
||||
name: "модальное слово внутри огороженного блока кода",
|
||||
setup: func(f files) {
|
||||
f["conventions/arch/time.md"] = archTime +
|
||||
"\n## Связано\n\n```\nДОЛЖЕН это не норма, а строка примера\n```\n"
|
||||
},
|
||||
}, {
|
||||
name: "заглавное SQL-слово в примере кода",
|
||||
setup: func(f files) {
|
||||
f["conventions/arch/time.md"] = strings.Replace(archTime,
|
||||
"**ПОЧЕМУ.** Без явного смещения не видно, в какой зоне запись сделана.",
|
||||
"**ПОЧЕМУ.** Без явного смещения не видно зоны.\n\n```sql\nSELECT 1 WHERE a AND b OR c\n```", 1)
|
||||
},
|
||||
}, {
|
||||
name: "норма языкового слоя ссылается на базовый слой своей темы",
|
||||
setup: func(f files) {
|
||||
f["conventions/lang/go/time.md"] = strings.Replace(goTime,
|
||||
"**ДОЛЖЕН.** Текущее время приходит из store.Now().",
|
||||
"**ДОЛЖЕН.** Текущее время приходит из store.Now() в форме TIME-1.", 1)
|
||||
},
|
||||
}, {
|
||||
name: "упоминание ступени в обосновании — не вторая норма",
|
||||
setup: func(f files) {
|
||||
f["conventions/arch/time.md"] = strings.Replace(archTime,
|
||||
"**ПОЧЕМУ.** Без явного смещения не видно, в какой зоне запись сделана.",
|
||||
"**ПОЧЕМУ.** Для ступени СЛЕДУЕТ это было бы честно, но здесь ломается сортировка.", 1)
|
||||
},
|
||||
}, {
|
||||
name: "заглушка снятого правила с датой и причиной",
|
||||
setup: func(f files) {
|
||||
f["conventions/arch/time.md"] = archTime +
|
||||
"\n### TIME-2. Ширина строки фиксируется\n\n**СНЯТО 2026-07-26.** Правило переехало в GTIM-1.\n"
|
||||
},
|
||||
}}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
f := layered()
|
||||
tc.setup(f)
|
||||
if got := run(t, f); len(got) != 0 {
|
||||
t.Fatalf("проверка сработала там, где не должна:\n%s", messages(got))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user