Files
healthlog/internal/hae/hae_test.go
T
av 01d0de59df разбор метрик HAE: фикстуры, канонизация, парсер
- tmp/research/fixtures.py собирает фикстуры из архива, вычищая измерения и
  сохраняя порядок ключей, форму литералов, выравнивание меток и невидимые
  символы; шесть фикстур в internal/hae/testdata
- internal/canon — общий дом канонической формы, полноты и хеша: числа читаются
  литералом через json.Number, округление до 12 значащих цифр
- internal/hae — разбор секции metrics, вывод слоя по метрике, разделение схем
  сна, координаты интервалом; recover внутри Parse, фаззинг
- миграция bucket и docs/database.md
2026-08-01 17:31:41 +03:00

547 lines
19 KiB
Go

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("точка без слоя доехала до хранилища")
}
}
})
}