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