разбор метрик HAE: фикстуры, канонизация, парсер
- tmp/research/fixtures.py собирает фикстуры из архива, вычищая измерения и сохраняя порядок ключей, форму литералов, выравнивание меток и невидимые символы; шесть фикстур в internal/hae/testdata - internal/canon — общий дом канонической формы, полноты и хеша: числа читаются литералом через json.Number, округление до 12 значащих цифр - internal/hae — разбор секции metrics, вывод слоя по метрике, разделение схем сна, координаты интервалом; recover внутри Parse, фаззинг - миграция bucket и docs/database.md
This commit is contained in:
@@ -0,0 +1,546 @@
|
||||
package hae_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.vakhrushev.me/av/healthlog/internal/hae"
|
||||
)
|
||||
|
||||
func load(t *testing.T, name string) []byte {
|
||||
t.Helper()
|
||||
|
||||
body, err := os.ReadFile(filepath.Join("testdata", name))
|
||||
if err != nil {
|
||||
t.Fatalf("фикстура %s: %v", name, err)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// layersOf собирает слой по имени метрики: слой обязан выводиться ПО МЕТРИКЕ,
|
||||
// а не по доставке, поэтому сверять его надо тоже по метрике.
|
||||
func layersOf(points []hae.Point) map[string]hae.Layer {
|
||||
out := make(map[string]hae.Layer)
|
||||
for _, p := range points {
|
||||
out[p.Metric] = p.Layer
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func countsOf(points []hae.Point) map[string]int {
|
||||
out := make(map[string]int)
|
||||
for _, p := range points {
|
||||
out[p.Metric]++
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Фикстуры собраны из живого архива с вычищенными значениями: выравнивание
|
||||
// меток, порядок ключей и форма литералов сохранены (см. testdata/README.md).
|
||||
func TestParseФикстурыЖивогоПотока(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
file string
|
||||
meta hae.Meta
|
||||
layer hae.Layer
|
||||
metrics map[string]hae.Layer
|
||||
}{
|
||||
{
|
||||
name: "минутная доставка",
|
||||
file: "minute.json",
|
||||
meta: hae.Meta{Aggregation: "Minutes"},
|
||||
layer: hae.LayerMinute,
|
||||
metrics: map[string]hae.Layer{
|
||||
"active_energy": hae.LayerMinute,
|
||||
"heart_rate_variability": hae.LayerMinute,
|
||||
"apple_stand_hour": hae.LayerHour, // плотная и своя
|
||||
"sleep_analysis_summary": hae.LayerDay, // слой назначен схемой
|
||||
"six_minute_walking_test_distance": hae.LayerMinute, // редкая наследует
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "часовая доставка",
|
||||
file: "hour.json",
|
||||
meta: hae.Meta{Aggregation: "Hours"},
|
||||
layer: hae.LayerHour,
|
||||
metrics: map[string]hae.Layer{
|
||||
"active_energy": hae.LayerHour,
|
||||
"apple_stand_hour": hae.LayerHour,
|
||||
"sleep_analysis_summary": hae.LayerDay,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "посекундная доставка",
|
||||
file: "raw.json",
|
||||
meta: hae.Meta{Aggregation: "Default"},
|
||||
layer: hae.LayerRaw,
|
||||
metrics: map[string]hae.Layer{
|
||||
"heart_rate": hae.LayerRaw,
|
||||
"resting_heart_rate": hae.LayerRaw,
|
||||
},
|
||||
},
|
||||
{
|
||||
// Перенастройка автоматизации: в одной доставке минутные,
|
||||
// посекундные и часовые метрики. Отнесение доставки к одному слою
|
||||
// сложило бы минутные точки с посекундными.
|
||||
name: "смешанная доставка",
|
||||
file: "mixed.json",
|
||||
meta: hae.Meta{Aggregation: "Default"},
|
||||
layer: hae.LayerRaw,
|
||||
metrics: map[string]hae.Layer{
|
||||
"heart_rate": hae.LayerRaw,
|
||||
"active_energy": hae.LayerMinute,
|
||||
"apple_stand_hour": hae.LayerHour,
|
||||
"sleep_analysis_summary": hae.LayerDay,
|
||||
"resting_heart_rate": hae.LayerRaw, // редкая наследует слой доставки
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
res, err := hae.Parse(load(t, c.file), c.meta)
|
||||
if err != nil {
|
||||
t.Fatalf("разбор: %v", err)
|
||||
}
|
||||
if res.Layer != c.layer {
|
||||
t.Errorf("слой доставки = %q, ожидался %q", res.Layer, c.layer)
|
||||
}
|
||||
|
||||
got := layersOf(res.Points)
|
||||
for metric, want := range c.metrics {
|
||||
if got[metric] != want {
|
||||
t.Errorf("%s: слой %q, ожидался %q", metric, got[metric], want)
|
||||
}
|
||||
}
|
||||
if res.SkippedMalformed != 0 {
|
||||
t.Errorf("живая фикстура дала %d неразобранных точек", res.SkippedMalformed)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Под именем sleep_analysis приезжают две несовместимые схемы. Разделение
|
||||
// выполняется ДО вывода слоя: сводок бывает больше порога плотности, и их
|
||||
// полуночные метки иначе назначили бы всей доставке слой hour.
|
||||
func TestParseРазводитДвеСхемыСна(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
res, err := hae.Parse(load(t, "minute.json"), hae.Meta{Aggregation: "Minutes"})
|
||||
if err != nil {
|
||||
t.Fatalf("разбор: %v", err)
|
||||
}
|
||||
|
||||
counts := countsOf(res.Points)
|
||||
if counts["sleep_analysis_summary"] == 0 {
|
||||
t.Fatal("суточная сводка сна не выделена в собственную метрику")
|
||||
}
|
||||
if counts["sleep_analysis"] != 0 {
|
||||
t.Errorf("под именем sleep_analysis осталось %d точек сводки", counts["sleep_analysis"])
|
||||
}
|
||||
|
||||
for _, p := range res.Points {
|
||||
if p.Metric == "sleep_analysis_summary" && p.Layer != hae.LayerDay {
|
||||
t.Errorf("слой сводки = %q, ожидался day", p.Layer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ключ по метке схлопнул бы эти три записи в одну: у них общий date и разные
|
||||
// интервалы. Живьём такое встречается в 22 доставках из 94 (находка 47).
|
||||
func TestParseСохраняетЗаписиСОднойМеткой(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
res, err := hae.Parse(load(t, "handmade_edge.json"), hae.Meta{})
|
||||
if err != nil {
|
||||
t.Fatalf("разбор: %v", err)
|
||||
}
|
||||
|
||||
var intervals []hae.Point
|
||||
for _, p := range res.Points {
|
||||
if p.Metric == "interval_points" {
|
||||
intervals = append(intervals, p)
|
||||
}
|
||||
}
|
||||
if len(intervals) != 3 {
|
||||
t.Fatalf("точек-интервалов %d, ожидалось 3", len(intervals))
|
||||
}
|
||||
|
||||
starts := map[time.Time]int{}
|
||||
ends := map[time.Time]int{}
|
||||
for _, p := range intervals {
|
||||
starts[p.Start]++
|
||||
ends[p.End]++
|
||||
if !p.End.After(p.Start) {
|
||||
t.Errorf("конец %v не позже начала %v", p.End, p.Start)
|
||||
}
|
||||
}
|
||||
if len(starts) != 1 {
|
||||
t.Errorf("начал %d, ожидалось одно общее", len(starts))
|
||||
}
|
||||
if len(ends) != 3 {
|
||||
t.Errorf("концов %d, ожидалось три разных", len(ends))
|
||||
}
|
||||
}
|
||||
|
||||
// Точка без end адресуется вырожденным интервалом: ключ одной формы для всех
|
||||
// точек, без ветвления по «классу метрики».
|
||||
func TestParseТочкаБезКонцаВырожденнаяВИнтервал(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
res, err := hae.Parse(load(t, "handmade_edge.json"), hae.Meta{})
|
||||
if err != nil {
|
||||
t.Fatalf("разбор: %v", err)
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, p := range res.Points {
|
||||
if p.Metric != "mixed_alignment" {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
if !p.End.Equal(p.Start) {
|
||||
t.Fatalf("конец %v не равен началу %v у точки-измерения", p.End, p.Start)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("метрика mixed_alignment не разобрана")
|
||||
}
|
||||
}
|
||||
|
||||
// Метка ровно на часе одновременно является и минутной, поэтому слой метрики —
|
||||
// самое мелкое встретившееся выравнивание, а не преобладающее.
|
||||
func TestParseСлойПоСамомуМелкомуВыравниванию(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
res, err := hae.Parse(load(t, "handmade_edge.json"), hae.Meta{})
|
||||
if err != nil {
|
||||
t.Fatalf("разбор: %v", err)
|
||||
}
|
||||
|
||||
if got := layersOf(res.Points)["mixed_alignment"]; got != hae.LayerMinute {
|
||||
t.Errorf("слой = %q, ожидался minute: десять меток часовые, одна минутная", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Незнакомое поле — норма: HAE меняется между версиями, и точка обязана
|
||||
// доехать до хранилища дословно, вместе с тем, чего разбор не понимает.
|
||||
func TestParseСохраняетТочкуДословно(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := load(t, "handmade_edge.json")
|
||||
res, err := hae.Parse(body, hae.Meta{})
|
||||
if err != nil {
|
||||
t.Fatalf("разбор: %v", err)
|
||||
}
|
||||
|
||||
var raw string
|
||||
for _, p := range res.Points {
|
||||
if p.Metric == "unknown_shape" {
|
||||
raw = string(p.Raw)
|
||||
}
|
||||
}
|
||||
if raw == "" {
|
||||
t.Fatal("метрика unknown_shape не разобрана")
|
||||
}
|
||||
|
||||
for _, want := range []string{
|
||||
`"inventedField"`, // незнакомое поле целиком
|
||||
`"nested"`, // вместе с вложенной структурой
|
||||
`1.0`, // литерал, который round-trip превратил бы в 1
|
||||
`9007199254740993`, // целое за пределами точности double
|
||||
`0.095231829624713534`, // дробь длиннее двенадцати значащих цифр
|
||||
} {
|
||||
if !strings.Contains(raw, want) {
|
||||
t.Errorf("в сохранённой точке нет %s:\n%s", want, raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Серия ударов — 93% объёма метрики heart_rate_variability и третий формат
|
||||
// времени. Разворачивать её в точки нельзя, преобразовывать эпоху — тоже:
|
||||
// time.Unix и обратно не гарантирует дословности.
|
||||
func TestParseНеТрогаетСериюУдаров(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
res, err := hae.Parse(load(t, "heartbeat_series.json"), hae.Meta{FallbackLayer: hae.LayerRaw})
|
||||
if err != nil {
|
||||
t.Fatalf("разбор: %v", err)
|
||||
}
|
||||
|
||||
if len(res.Points) != 2 {
|
||||
t.Fatalf("точек %d, ожидалось 2: серия не должна разворачиваться", len(res.Points))
|
||||
}
|
||||
|
||||
var series []struct {
|
||||
Date json.Number `json:"date"`
|
||||
}
|
||||
var point struct {
|
||||
Series json.RawMessage `json:"heartbeatSeries"`
|
||||
}
|
||||
if err := json.Unmarshal(res.Points[0].Raw, &point); err != nil {
|
||||
t.Fatalf("точка не разбирается: %v", err)
|
||||
}
|
||||
if len(point.Series) == 0 {
|
||||
t.Fatal("в точке нет heartbeatSeries")
|
||||
}
|
||||
dec := json.NewDecoder(strings.NewReader(string(point.Series)))
|
||||
dec.UseNumber()
|
||||
if err := dec.Decode(&series); err != nil {
|
||||
t.Fatalf("серия не разбирается: %v", err)
|
||||
}
|
||||
if len(series) == 0 {
|
||||
t.Fatal("серия пуста")
|
||||
}
|
||||
// Эпоха внутри серии осталась дробным числом, а не превратилась в строку
|
||||
// или в целое: разбор её не касался.
|
||||
if !strings.Contains(series[0].Date.String(), ".") {
|
||||
t.Errorf("эпоха внутри серии изменена: %s", series[0].Date)
|
||||
}
|
||||
}
|
||||
|
||||
// Доставка без плотных метрик: измерено 2 такие из 89, обе с заголовком
|
||||
// Default. Молчаливый выбор raw создал бы призрачный разрез, который поедет в
|
||||
// каталог и в правило Read API «самый мелкий слой, покрывающий диапазон».
|
||||
func TestParseДоставкаБезПлотныхМетрик(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := load(t, "sparse_sleep.json")
|
||||
|
||||
t.Run("наследовать нечего и заголовок ненадёжен", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
res, err := hae.Parse(body, hae.Meta{Aggregation: "Default"})
|
||||
if !errors.Is(err, hae.ErrLayerUnknown) {
|
||||
t.Fatalf("ошибка %v, ожидалась ErrLayerUnknown", err)
|
||||
}
|
||||
if len(res.Points) != 0 {
|
||||
t.Errorf("сохранено %d точек при неизвестном слое", len(res.Points))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("наследует слой автоматизации", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
res, err := hae.Parse(body, hae.Meta{Aggregation: "Default", FallbackLayer: hae.LayerMinute})
|
||||
if err != nil {
|
||||
t.Fatalf("разбор: %v", err)
|
||||
}
|
||||
if res.Layer != hae.LayerMinute {
|
||||
t.Errorf("слой = %q, ожидался унаследованный minute", res.Layer)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("надёжный заголовок как последнее средство", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
res, err := hae.Parse(body, hae.Meta{Aggregation: "Hours"})
|
||||
if err != nil {
|
||||
t.Fatalf("разбор: %v", err)
|
||||
}
|
||||
if res.Layer != hae.LayerHour {
|
||||
t.Errorf("слой = %q, ожидался hour из заголовка", res.Layer)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Заголовок Default не означает режима, поэтому расхождение с ним не
|
||||
// фиксируется: сигнал утонул бы в собственном шуме на потоке в пять минут.
|
||||
func TestParseРасхождениеСлояТолькоПротивНадёжногоЗаголовка(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := load(t, "raw.json") // выводится raw
|
||||
|
||||
res, err := hae.Parse(body, hae.Meta{Aggregation: "Default"})
|
||||
if err != nil {
|
||||
t.Fatalf("разбор: %v", err)
|
||||
}
|
||||
if res.LayerMismatch {
|
||||
t.Error("расхождение зафиксировано против заголовка Default")
|
||||
}
|
||||
|
||||
res, err = hae.Parse(body, hae.Meta{Aggregation: "Hours"})
|
||||
if err != nil {
|
||||
t.Fatalf("разбор: %v", err)
|
||||
}
|
||||
if !res.LayerMismatch {
|
||||
t.Error("расхождение с надёжным заголовком Hours не зафиксировано")
|
||||
}
|
||||
if res.Layer != hae.LayerRaw {
|
||||
t.Errorf("слой = %q: сохранять надо по выведенному, а не по заголовку", res.Layer)
|
||||
}
|
||||
}
|
||||
|
||||
// Частичный исход — счётчиками, а не ошибкой: пакет, у которого не разобралась
|
||||
// часть точек, доезжает остальным содержимым.
|
||||
func TestParseПропускаетНеразобранныеТочки(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
res, err := hae.Parse(load(t, "handmade_edge.json"), hae.Meta{})
|
||||
if err != nil {
|
||||
t.Fatalf("разбор: %v", err)
|
||||
}
|
||||
|
||||
if res.SkippedNoTime != 3 {
|
||||
t.Errorf("точек без метки %d, ожидалось 3", res.SkippedNoTime)
|
||||
}
|
||||
if res.SkippedMalformed != 1 {
|
||||
t.Errorf("неразобранных точек %d, ожидалась 1", res.SkippedMalformed)
|
||||
}
|
||||
if countsOf(res.Points)["broken_timestamps"] != 0 {
|
||||
t.Error("точка с непонятной меткой попала в хранилище")
|
||||
}
|
||||
}
|
||||
|
||||
// Время нормализовано в UTC, но зона не потеряна: без офсета неизвестно, в
|
||||
// каком поясе человек находился.
|
||||
func TestParseСохраняетСмещениеЗоны(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
res, err := hae.Parse(load(t, "handmade_edge.json"), hae.Meta{})
|
||||
if err != nil {
|
||||
t.Fatalf("разбор: %v", err)
|
||||
}
|
||||
|
||||
for _, p := range res.Points {
|
||||
if p.Metric != "mixed_alignment" {
|
||||
continue
|
||||
}
|
||||
if p.Start.Location() != time.UTC {
|
||||
t.Errorf("время не в UTC: %v", p.Start)
|
||||
}
|
||||
if p.OffsetSeconds != 3*3600 {
|
||||
t.Errorf("смещение %d секунд, ожидалось %d", p.OffsetSeconds, 3*3600)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatal("метрика mixed_alignment не разобрана")
|
||||
}
|
||||
|
||||
// Разбор чужого формата обязан отвечать ошибкой, а не паникой: приём не имеет
|
||||
// права упасть из-за того, что HAE прислал невиданное.
|
||||
func TestParseВраждебныйВход(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
wantErr error
|
||||
}{
|
||||
{"усечённое тело", `{"data":{"metrics":[`, hae.ErrMalformed},
|
||||
{"не JSON вовсе", `не json`, hae.ErrMalformed},
|
||||
{"пустое тело", ``, hae.ErrMalformed},
|
||||
{"null вместо объекта", `null`, nil},
|
||||
{"массив вместо data", `{"data":[1,2,3]}`, hae.ErrMalformed},
|
||||
{"массив вместо metrics", `{"data":{"metrics":{}}}`, hae.ErrMalformed},
|
||||
{"метрики отсутствуют", `{"data":{"workouts":[]}}`, nil},
|
||||
{"точка не объект", `{"data":{"metrics":[{"name":"m","data":[1,2]}]}}`, nil},
|
||||
{"число вместо строки даты", `{"data":{"metrics":[{"name":"m","data":[{"date":1}]}]}}`, nil},
|
||||
{"дата не того формата", `{"data":{"metrics":[{"name":"m","data":[{"date":"2025-06-05T10:00:00Z"}]}]}}`, nil},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := hae.Parse([]byte(c.body), hae.Meta{})
|
||||
if c.wantErr == nil {
|
||||
if err != nil && !errors.Is(err, hae.ErrLayerUnknown) {
|
||||
t.Fatalf("неожиданная ошибка: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if !errors.Is(err, c.wantErr) {
|
||||
t.Fatalf("ошибка %v, ожидалась %v", err, c.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Разбор — чистая функция: ни времени, ни генерации идентификаторов. Два
|
||||
// вызова на одном входе обязаны совпасть, иначе пересборка витрины из архива
|
||||
// даст не то состояние, что живой приём.
|
||||
func TestParseДетерминирован(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := load(t, "mixed.json")
|
||||
meta := hae.Meta{Aggregation: "Default"}
|
||||
|
||||
a, err := hae.Parse(body, meta)
|
||||
if err != nil {
|
||||
t.Fatalf("первый разбор: %v", err)
|
||||
}
|
||||
b, err := hae.Parse(body, meta)
|
||||
if err != nil {
|
||||
t.Fatalf("второй разбор: %v", err)
|
||||
}
|
||||
|
||||
if len(a.Points) != len(b.Points) {
|
||||
t.Fatalf("точек %d против %d", len(a.Points), len(b.Points))
|
||||
}
|
||||
for i := range a.Points {
|
||||
if a.Points[i].Metric != b.Points[i].Metric ||
|
||||
!a.Points[i].Start.Equal(b.Points[i].Start) ||
|
||||
!a.Points[i].End.Equal(b.Points[i].End) ||
|
||||
a.Points[i].Layer != b.Points[i].Layer ||
|
||||
string(a.Points[i].Raw) != string(b.Points[i].Raw) {
|
||||
t.Fatalf("точка %d разошлась между вызовами", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Тело не должно портиться разбором: оно уже лежит в архиве, и пересборка
|
||||
// прочитает те же байты.
|
||||
func TestParseНеПортитТело(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := load(t, "mixed.json")
|
||||
before := string(body)
|
||||
|
||||
if _, err := hae.Parse(body, hae.Meta{Aggregation: "Default"}); err != nil {
|
||||
t.Fatalf("разбор: %v", err)
|
||||
}
|
||||
if string(body) != before {
|
||||
t.Error("разбор изменил тело доставки")
|
||||
}
|
||||
}
|
||||
|
||||
func FuzzParse(f *testing.F) {
|
||||
for _, name := range []string{"minute.json", "sparse_sleep.json", "handmade_edge.json"} {
|
||||
body, err := os.ReadFile(filepath.Join("testdata", name))
|
||||
if err != nil {
|
||||
f.Fatalf("фикстура %s: %v", name, err)
|
||||
}
|
||||
f.Add(body)
|
||||
}
|
||||
f.Add([]byte(`{"data":{"metrics":[{"name":"m","units":"u","data":[{"date":"2025-06-05 10:00:00 +0300"}]}]}}`))
|
||||
|
||||
f.Fuzz(func(t *testing.T, body []byte) {
|
||||
// Единственное требование: разбор произвольного входа не роняет
|
||||
// процесс и не отдаёт точку без координат.
|
||||
res, err := hae.Parse(body, hae.Meta{})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, p := range res.Points {
|
||||
if p.Start.IsZero() {
|
||||
t.Fatal("точка без метки времени доехала до хранилища")
|
||||
}
|
||||
if p.End.Before(p.Start) {
|
||||
t.Fatalf("конец раньше начала: %v < %v", p.End, p.Start)
|
||||
}
|
||||
if p.Layer == "" {
|
||||
t.Fatal("точка без слоя доехала до хранилища")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user