- комментарии, тексты ошибок, вывод CLI и сообщения тестов теперь на английском - по-русски остались только литералы словаря ru и содержимое фикстур: это данные под проверкой, а не текст инструмента - согласование числительных в итоге упростилось до английского plural
237 lines
7.6 KiB
Go
237 lines
7.6 KiB
Go
package check_test
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// A suite with two layers of one topic: a base architectural layer and a
|
|
// language layer on top of it. Everything about axes, extends and the boundary
|
|
// of a self-sufficient norm is checked here — a single layer cannot express it.
|
|
|
|
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("a sound layered suite produced findings:\n%s", messages(got))
|
|
}
|
|
}
|
|
|
|
func TestLayeredChecks(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
setup func(files)
|
|
want string
|
|
}{{
|
|
name: "axis in the front matter diverges from the path",
|
|
setup: func(f files) {
|
|
f["conventions/lang/go/time.md"] = strings.Replace(goTime, "lang: go", "lang: python", 1)
|
|
},
|
|
want: `the path puts the file on axis lang=go, while the front matter declares lang="python"`,
|
|
}, {
|
|
name: "extends leads into a foreign topic",
|
|
setup: func(f files) {
|
|
f["conventions/lang/go/time.md"] = strings.Replace(goTime,
|
|
"extends: arch/time.md", "extends: arch/logging.md", 1)
|
|
},
|
|
want: `with topic "logging", while the file carries topic "time"`,
|
|
}, {
|
|
name: "extends leads into a file that does not exist",
|
|
setup: func(f files) {
|
|
f["conventions/lang/go/time.md"] = strings.Replace(goTime,
|
|
"extends: arch/time.md", "extends: arch/clocks.md", 1)
|
|
},
|
|
want: `and the suite holds no such file`,
|
|
}, {
|
|
name: "topic with two layers lacking axis keys",
|
|
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: "more than one layer without axis keys",
|
|
}, {
|
|
name: "norm references the prefix of a foreign topic",
|
|
setup: func(f files) {
|
|
f["conventions/lang/go/time.md"] = strings.Replace(goTime,
|
|
"**ДОЛЖЕН.** Текущее время приходит из store.Now().",
|
|
"**ДОЛЖЕН.** Текущее время приходит из store.Now() и пишется по SLOG-1.", 1)
|
|
},
|
|
want: `refers to SLOG-1 from the foreign topic "logging"`,
|
|
}, {
|
|
name: "norm references a non-base layer of its own topic",
|
|
setup: func(f files) {
|
|
f["conventions/arch/time.md"] = strings.Replace(archTime,
|
|
"**ДОЛЖЕН.** Момент времени записывается с суффиксом Z.",
|
|
"**ДОЛЖЕН.** Момент времени записывается с суффиксом Z, как требует GTIM-1.", 1)
|
|
},
|
|
want: "a layer of its own topic but not the base one",
|
|
}}
|
|
|
|
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("the check did not fire\nwanted: %s\ngot:\n%s", tc.want, got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestNoFalsePositives gathers the cases where a check must stay silent. A
|
|
// false positive costs more here than a miss: a check that goes red on a sound
|
|
// file gets switched off altogether.
|
|
func TestNoFalsePositives(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
setup func(files)
|
|
}{{
|
|
name: "an identifier in backticks is a sample of notation, not a reference",
|
|
setup: func(f files) {
|
|
f["conventions/arch/time.md"] = strings.Replace(archTime,
|
|
"Без явного смещения",
|
|
"На правило ссылаются идентификатором (`TIME-99`). Без явного смещения", 1)
|
|
},
|
|
}, {
|
|
name: "a modal word inside a fenced code block",
|
|
setup: func(f files) {
|
|
f["conventions/arch/time.md"] = archTime +
|
|
"\n## Связано\n\n```\nДОЛЖЕН это не норма, а строка примера\n```\n"
|
|
},
|
|
}, {
|
|
name: "an uppercase SQL keyword in a code sample",
|
|
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: "a language layer's norm references the base layer of its own topic",
|
|
setup: func(f files) {
|
|
f["conventions/lang/go/time.md"] = strings.Replace(goTime,
|
|
"**ДОЛЖЕН.** Текущее время приходит из store.Now().",
|
|
"**ДОЛЖЕН.** Текущее время приходит из store.Now() в форме TIME-1.", 1)
|
|
},
|
|
}, {
|
|
name: "a mention of a step in the rationale is not a second norm",
|
|
setup: func(f files) {
|
|
f["conventions/arch/time.md"] = strings.Replace(archTime,
|
|
"**ПОЧЕМУ.** Без явного смещения не видно, в какой зоне запись сделана.",
|
|
"**ПОЧЕМУ.** Для ступени СЛЕДУЕТ это было бы честно, но здесь ломается сортировка.", 1)
|
|
},
|
|
}, {
|
|
name: "a stub of a retired rule with a date and a reason",
|
|
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("a check fired where it must not:\n%s", messages(got))
|
|
}
|
|
})
|
|
}
|
|
}
|