Files
healthlog/internal/hae/hae_test.go
T
av f8200f7f80 feat: разбор и хранение тренировок и состояния разума
- секции `workouts` и `stateOfMind` покрыты разбором: тренировка лежит одной
  строкой вместе с маршрутом и внутренними рядами, запись — по ключу `род + id`;
  миграция 00007 заводит обе таблицы и возвращает в очередь `partial`-доставки
  с этими ключами
- сущность заменяется целиком, но условно: приехавшая побеждает, если не теряет
  содержания сохранённой (множество ключей и длины верхнеуровневых массивов), а
  при равном содержании выигрывает версия из более поздней доставки ЖУРНАЛА —
  «побеждает приехавшая» было бы функцией порядка свёртки, и живая витрина
  расходилась бы с пересборкой молча
- отпечаток витрины покрывает тренировки и записи и снимается одним снимком
  базы; отчёт `reindex` считает «было и стало» по каждой единице хранения
2026-08-02 13:05:16 +03:00

705 lines
25 KiB
Go

package hae_test
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"time"
"unicode/utf8"
"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("точка без слоя доехала до хранилища")
}
}
})
}
// Непокрытая секция неотличима от разобранной доставки с пустой секцией
// метрик, если её не назвать: ретеншен, ориентируясь на статус, срезал бы
// тела, которые для секций без экспорта Apple единственный источник. С тех пор
// как workouts и stateOfMind стали покрытыми, роль непокрытой в фикстуре
// играют секции, которых поток ещё не приносил.
func TestParseПеречисляетНепокрытыеСекции(t *testing.T) {
t.Parallel()
res, err := hae.Parse(load(t, "uncovered_sections.json"), hae.Meta{})
if err != nil {
t.Fatalf("разбор: %v", err)
}
want := []string{"ecg", "symptoms"}
if !slices.Equal(res.Uncovered, want) {
t.Errorf("непокрытые %v, ожидались %v", res.Uncovered, want)
}
if len(res.Points) == 0 {
t.Error("точки метрик обязаны сохраниться: непокрытая секция рядом им не мешает")
}
if res.UncoveredDropped != 0 {
t.Errorf("отброшено %d имён, ожидалось 0", res.UncoveredDropped)
}
}
func TestParseНепокрытыеСекцииГраницыИДетерминизм(t *testing.T) {
t.Parallel()
t.Run("доставка из одной непокрытой секции", func(t *testing.T) {
t.Parallel()
res, err := hae.Parse([]byte(`{"data":{"ecg":[{"x":1}]}}`), hae.Meta{})
if err != nil {
t.Fatalf("разбор: %v", err)
}
if !slices.Equal(res.Uncovered, []string{"ecg"}) {
t.Errorf("непокрытые %v", res.Uncovered)
}
if len(res.Points) != 0 {
t.Errorf("точек %d, ожидалось 0", len(res.Points))
}
})
t.Run("доставка из одних метрик списка не даёт", func(t *testing.T) {
t.Parallel()
res, err := hae.Parse([]byte(`{"data":{"metrics":[]}}`), hae.Meta{})
if err != nil {
t.Fatalf("разбор: %v", err)
}
if len(res.Uncovered) != 0 {
t.Errorf("непокрытые %v, ожидался пустой список", res.Uncovered)
}
})
// Порядок ключей в JSON от HAE нестабилен, а список уезжает в базу и
// сравнивается между доставками: зависящий от порядка на проводе список
// сравнивать нельзя.
t.Run("порядок и повторы не влияют", func(t *testing.T) {
t.Parallel()
bodies := []string{
`{"data":{"symptoms":[],"medications":[],"ecg":[]}}`,
`{"data":{"ecg":[],"symptoms":[],"medications":[]}}`,
`{"data":{"medications":[],"ecg":[],"symptoms":[],"ecg":[]}}`,
}
want := []string{"ecg", "medications", "symptoms"}
for _, b := range bodies {
res, err := hae.Parse([]byte(b), hae.Meta{})
if err != nil {
t.Fatalf("разбор %s: %v", b, err)
}
if !slices.Equal(res.Uncovered, want) {
t.Errorf("тело %s дало %v, ожидалось %v", b, res.Uncovered, want)
}
}
})
// Повтор ключа metrics JSON допускает; секции объединяются, а не побеждает
// последняя — терять точки молча нельзя.
t.Run("повтор metrics объединяет секции", func(t *testing.T) {
t.Parallel()
const body = `{"data":{"metrics":[{"name":"a","data":[]}],"metrics":[{"name":"b","data":[]}]}}`
res, err := hae.Parse([]byte(body), hae.Meta{})
if err != nil {
t.Fatalf("разбор: %v", err)
}
if res.Metrics != 2 {
t.Errorf("метрик %d, ожидалось 2 — секции обязаны объединиться", res.Metrics)
}
})
t.Run("границы списка видны", func(t *testing.T) {
t.Parallel()
var sb strings.Builder
sb.WriteString(`{"data":{`)
for i := range 40 {
if i > 0 {
sb.WriteByte(',')
}
fmt.Fprintf(&sb, `"s%02d":[]`, i)
}
sb.WriteString(`}}`)
res, err := hae.Parse([]byte(sb.String()), hae.Meta{})
if err != nil {
t.Fatalf("разбор: %v", err)
}
if len(res.Uncovered) != 32 {
t.Errorf("имён %d, ожидалось 32", len(res.Uncovered))
}
if res.UncoveredDropped != 8 {
t.Errorf("отброшено %d, ожидалось 8 — иначе «ровно 32» неотличимо от «пришло пятьсот»", res.UncoveredDropped)
}
})
t.Run("длинное имя обрезано и помечено", func(t *testing.T) {
t.Parallel()
long := strings.Repeat("щ", 60) // 120 байт
res, err := hae.Parse([]byte(`{"data":{"`+long+`":[]}}`), hae.Meta{})
if err != nil {
t.Fatalf("разбор: %v", err)
}
if len(res.Uncovered) != 1 {
t.Fatalf("непокрытые %v", res.Uncovered)
}
got := res.Uncovered[0]
if !strings.HasSuffix(got, "…") {
t.Errorf("имя %q без маркера обрезки: обрезка не инъективна и обязана быть видимой", got)
}
if !utf8.ValidString(got) {
t.Errorf("имя %q обрезано посреди руны", got)
}
})
// Ошибка после разобранной секции metrics: правило прежнее — при ошибке
// точек нет вовсе, иначе часть точек оказалась бы в витрине под статусом,
// по которому доставку никто не подберёт.
t.Run("обрыв после метрик точек не отдаёт", func(t *testing.T) {
t.Parallel()
const body = `{"data":{"metrics":[{"name":"a","data":[{"date":"2025-06-05 10:00:00 +0300","qty":1}]}],"work`
res, err := hae.Parse([]byte(body), hae.Meta{})
if !errors.Is(err, hae.ErrMalformed) {
t.Fatalf("ошибка %v, ожидалась ErrMalformed", err)
}
if len(res.Points) != 0 {
t.Errorf("точек %d, ожидалось 0", len(res.Points))
}
})
}