package hae_test import ( "encoding/json" "strings" "testing" "git.vakhrushev.me/av/healthlog/internal/hae" ) func parseFixture(t *testing.T, name string) hae.Result { t.Helper() res, err := hae.Parse(load(t, name), hae.Meta{}) if err != nil { t.Fatalf("разбор %s: %v", name, err) } return res } // Тренировка хранится дословно: маршрут и внутренние ряды остаются теми же // байтами, какими пришли. Раскладывать их по колонкам значило бы решить за // Apple, что в тренировке главное. func TestParseТренировкаСМаршрутом(t *testing.T) { t.Parallel() res := parseFixture(t, "workout_route.json") var w hae.Entity for _, e := range res.Workouts { if strings.Contains(string(e.Raw), `"route"`) { w = e } } if w.ID == "" { t.Fatal("уличной тренировки с маршрутом в фикстуре не нашлось") } if w.Name == "" { t.Error("имя пусто — по нему идёт выборка заголовков") } if !w.End.After(w.Start) { t.Errorf("интервал %v — %v", w.Start, w.End) } if w.OffsetSeconds != 3*3600 { t.Errorf("офсет %d, ожидался 10800", w.OffsetSeconds) } if w.Duration == nil || *w.Duration <= 0 { t.Errorf("длительность %v — обязана браться из тела", w.Duration) } var body map[string]json.RawMessage if err := json.Unmarshal(w.Raw, &body); err != nil { t.Fatalf("содержимое не разбирается: %v", err) } for _, key := range []string{"route", "heartRateData", "activeEnergy", "heartRateRecovery"} { if _, ok := body[key]; !ok { t.Errorf("в содержимом нет %q — внутренние ряды обязаны храниться дословно", key) } } // Точки маршрута проходят исходными байтами: их метка (`timestamp`) // меткой сущности не является и не разбирается. if !strings.Contains(string(body["route"]), "timestamp") { t.Error("точки маршрута потеряли своё поле времени") } } // Пульс приезжает дважды — в общем потоке метрик и внутри тренировки. Это // разные таблицы; смешение задвоило бы ряд. func TestParseРядПульсаТренировкиНеСтановитсяМетрикой(t *testing.T) { t.Parallel() res := parseFixture(t, "workout_route.json") if len(res.Points) != 0 { t.Errorf("точек %d, ожидалось 0: доставка несёт только тренировки", len(res.Points)) } if res.Metrics != 0 { t.Errorf("метрик %d, ожидалось 0", res.Metrics) } if len(res.Uncovered) != 0 { t.Errorf("непокрытые %v, ожидался пустой список", res.Uncovered) } } // Набор полей тренировки зависит от её типа: у домашней нет маршрута, зато // есть температура и влажность. Фиксированной схемы не существует. func TestParseТренировкаБезМаршрута(t *testing.T) { t.Parallel() res := parseFixture(t, "workout_indoor.json") if len(res.Workouts) == 0 { t.Fatal("тренировок нет") } var body map[string]json.RawMessage if err := json.Unmarshal(res.Workouts[0].Raw, &body); err != nil { t.Fatalf("содержимое не разбирается: %v", err) } if _, ok := body["route"]; ok { t.Error("у домашней тренировки взялся маршрут") } for _, key := range []string{"temperature", "humidity", "intensity"} { if _, ok := body[key]; !ok { t.Errorf("в содержимом нет %q", key) } } } // stateOfMind живёт по другим соглашениям: RFC 3339 в UTC, коды HealthKit // вместо переводов, поля source нет вовсе. func TestParseСостояниеРазума(t *testing.T) { t.Parallel() res := parseFixture(t, "state_of_mind.json") if len(res.Records) == 0 { t.Fatal("записей нет") } for _, r := range res.Records { if r.Kind != "stateOfMind" { t.Errorf("род %q, ожидался stateOfMind — имя секции хранится дословно", r.Kind) } if r.ID == "" { t.Error("идентификатор пуст") } // HAE прислал UTC — офсет ноль. Это значит «источник прислал UTC», а не // «человек был в Гринвиче»: местной зоны у секции в потоке нет вовсе. if r.OffsetSeconds != 0 { t.Errorf("офсет %d, ожидался 0", r.OffsetSeconds) } if r.Start.IsZero() { t.Error("метка не разобралась — RFC 3339 обязан приниматься") } } if len(res.Uncovered) != 0 { t.Errorf("непокрытые %v, ожидался пустой список", res.Uncovered) } } // Пропуск одного элемента не уносит соседей, и у каждого класса свой счётчик: // тело остаётся в архиве, а вернуть сущность может только пересборка. func TestParseКраевыеСлучаиСущностей(t *testing.T) { t.Parallel() res := parseFixture(t, "handmade_entities.json") byID := make(map[string]hae.Entity, len(res.Workouts)) for _, w := range res.Workouts { byID[w.ID] = w } // Пустой id, отсутствующий id, id длиннее предела и id не строкой — один // счётчик на четыре случая: исход у них общий, сущность не адресуема. if res.SkippedNoID != 4 { t.Errorf("пропущено по идентификатору %d, ожидалось 4", res.SkippedNoID) } // Метка не разбирается, метки нет вовсе и начало приехало не строкой. if res.SkippedEntityNoTime != 3 { t.Errorf("пропущено по метке %d, ожидалось 3", res.SkippedEntityNoTime) } // Элемент, не являющийся объектом. if res.SkippedEntityMalformed != 1 { t.Errorf("пропущено по форме %d, ожидалось 1", res.SkippedEntityMalformed) } t.Run("нечитаемый конец не отбрасывает тренировку", func(t *testing.T) { w, ok := byID["00000000-0000-4000-8000-000000000003"] if !ok { t.Fatal("тренировка с нечитаемым концом потерялась целиком") } if !w.End.Equal(w.Start) { t.Errorf("конец %v, ожидался равным началу %v", w.End, w.Start) } if !strings.Contains(string(w.Raw), "никогда") { t.Error("исходное значение конца не сохранилось дословно") } }) // Значение не того ТИПА стоит одного поля, а не сущности: иначе `name`, // приехавшее числом, уносит тренировку вместе с маршрутом, а доставка при // этом числится разобранной. t.Run("имя числом не уносит тренировку", func(t *testing.T) { w, ok := byID["00000000-0000-4000-8000-00000000000c"] if !ok { t.Fatal("тренировка с именем-числом потерялась целиком") } if w.Name != "" { t.Errorf("имя %q, ожидалось пустое", w.Name) } if !strings.Contains(string(w.Raw), `"lat"`) { t.Error("маршрут не сохранился дословно") } }) t.Run("конец числом не уносит тренировку", func(t *testing.T) { w, ok := byID["00000000-0000-4000-8000-00000000000d"] if !ok { t.Fatal("тренировка с концом-числом потерялась целиком") } if !w.End.Equal(w.Start) { t.Errorf("конец %v, ожидался равным началу %v", w.End, w.Start) } }) // Фолбэк `start → date` существует для сущностей, у которых `start` не // прислан ВОВСЕ. Непонятое значение `start` фолбэка не получает: подстановка // другого поля дала бы метку другого момента времени, неотличимую от // настоящей и ничем не считаемую. t.Run("начало числом не подменяется полем date", func(t *testing.T) { if _, ok := byID["00000000-0000-4000-8000-00000000000e"]; ok { t.Error("нестроковое начало молча заменено меткой из date") } }) t.Run("идентификатор числом пропускает сущность", func(t *testing.T) { for id := range byID { if id == "42" { t.Error("нестроковый идентификатор приведён к строке — идентичность выдумана за источник") } } }) t.Run("нечисловая длительность не становится нулём", func(t *testing.T) { w := byID["00000000-0000-4000-8000-000000000004"] if w.Duration != nil { t.Errorf("длительность %v, ожидалось отсутствие", *w.Duration) } }) t.Run("отсутствующая длительность отличима от нуля", func(t *testing.T) { w := byID["00000000-0000-4000-8000-000000000005"] if w.Duration != nil { t.Errorf("длительность %v, ожидалось отсутствие", *w.Duration) } }) t.Run("начало берётся из date, когда start отсутствует", func(t *testing.T) { w, ok := byID["00000000-0000-4000-8000-000000000006"] if !ok { t.Fatal("тренировка с меткой в date потерялась") } if w.Start.IsZero() { t.Error("метка не разобралась") } }) t.Run("незнакомое поле переживает разбор дословно", func(t *testing.T) { w := byID["00000000-0000-4000-8000-000000000009"] for _, lit := range []string{"невиданноеПоле", "1.0", "9007199254740993", "0.123456789012345678"} { if !strings.Contains(string(w.Raw), lit) { t.Errorf("литерал %q потерян при разборе", lit) } } }) t.Run("запись со временем в формате метрик тоже разбирается", func(t *testing.T) { var daily *hae.Entity for i, r := range res.Records { if strings.Contains(string(r.Raw), "daily_mood") { daily = &res.Records[i] } } if daily == nil { t.Fatal("запись daily_mood потерялась") } if daily.OffsetSeconds != 3*3600 { t.Errorf("офсет %d, ожидался 10800: формат метрик обязан приниматься", daily.OffsetSeconds) } }) } // Отказ разбора — операция «всё или ничего»: ошибка после уже разобранной // секции не имеет права оставить сущности в результате. func TestParseОбрывПослеСекцииТренировокНеОтдаётСущностей(t *testing.T) { t.Parallel() const body = `{"data":{"workouts":[{"id":"w1","start":"2025-06-05 10:00:00 +0300"}],"metrics":` res, err := hae.Parse([]byte(body), hae.Meta{}) if err == nil { t.Fatal("оборванное тело разобралось без ошибки") } if len(res.Workouts) != 0 { t.Errorf("сущностей %d, ожидалось 0", len(res.Workouts)) } } // Невыводимый слой — тоже «всё или ничего»: доставка целиком уходит в failed, // иначе она получила бы failed при частично записанной витрине. func TestParseНевыводимыйСлойНеОтдаётСущностей(t *testing.T) { t.Parallel() const body = `{"data":{ "metrics":[{"name":"vo2_max","units":"ml/kg*min","data":[{"date":"2025-06-05 10:11:12 +0300","qty":1}]}], "stateOfMind":[{"id":"e1","start":"2025-06-05T18:00:00Z","valence":0.5}]}}` res, err := hae.Parse([]byte(body), hae.Meta{Aggregation: "Default"}) if err == nil { t.Fatal("доставка без выводимого слоя разобралась без ошибки") } if len(res.Records) != 0 { t.Errorf("записей %d, ожидалось 0", len(res.Records)) } if len(res.Points) != 0 { t.Errorf("точек %d, ожидалось 0", len(res.Points)) } } // Повтор ключа покрытой секции объединяет её, а не отдаёт победу последней. func TestParseПовторСекцииТренировокОбъединяет(t *testing.T) { t.Parallel() const body = `{"data":{ "workouts":[{"id":"w1","start":"2025-06-05 10:00:00 +0300"}], "workouts":[{"id":"w2","start":"2025-06-05 11:00:00 +0300"}]}}` res, err := hae.Parse([]byte(body), hae.Meta{}) if err != nil { t.Fatalf("разбор: %v", err) } if len(res.Workouts) != 2 { t.Errorf("тренировок %d, ожидалось 2 — секции обязаны объединиться", len(res.Workouts)) } } // Повтор самого члена `data` накапливает результаты: присваивание теряло бы // секции первого члена молча — их имена уже отмечены и во второй список не // попали бы. func TestParseПовторЧленаDataНакапливает(t *testing.T) { t.Parallel() const body = `{"data":{"ecg":[{"id":"e"}]},"data":{"workouts":[{"id":"w1","start":"2025-06-05 10:00:00 +0300"}]}}` res, err := hae.Parse([]byte(body), hae.Meta{}) if err != nil { t.Fatalf("разбор: %v", err) } if len(res.Workouts) != 1 { t.Errorf("тренировок %d, ожидалась 1", len(res.Workouts)) } if len(res.Uncovered) != 1 || res.Uncovered[0] != "ecg" { t.Errorf("непокрытые %v, ожидался [ecg] — иначе секция потеряна молча", res.Uncovered) } } // Непокрытой секцией с собственными `id` остаются те, чьей формы никто не // видел: разбор вслепую хуже честного «не покрыто». func TestParseНепокрытыеСекцииССобственнымиID(t *testing.T) { t.Parallel() const body = `{"data":{"ecg":[{"id":"e1","start":"2025-06-05 10:00:00 +0300"}]}}` res, err := hae.Parse([]byte(body), hae.Meta{}) if err != nil { t.Fatalf("разбор: %v", err) } if len(res.Records) != 0 { t.Errorf("записей %d, ожидалось 0", len(res.Records)) } if len(res.Uncovered) != 1 || res.Uncovered[0] != "ecg" { t.Errorf("непокрытые %v", res.Uncovered) } } // Элемент, который сам не объект, обязан идти в СВОЙ счётчик: сменившаяся форма // секции и сменившаяся форма идентификатора лечатся по-разному. `null` при этом // самый коварный — `json.Unmarshal` считает его законным no-op и не ошибается. func TestParseЭлементНеОбъектИдётВСвойСчётчик(t *testing.T) { t.Parallel() res, err := hae.Parse([]byte(`{"data":{"workouts":[null,"строка",42,[1,2]]}}`), hae.Meta{}) if err != nil { t.Fatalf("разбор: %v", err) } if res.SkippedEntityMalformed != 4 { t.Errorf("не разобралось как объект %d, ожидалось 4", res.SkippedEntityMalformed) } if res.SkippedNoID != 0 { t.Errorf("пропущено по идентификатору %d, ожидалось 0: форма секции — не форма id", res.SkippedNoID) } } // Повтор ключа JSON допускает, и весь разбор проекта пользуется семантикой // «побеждает последнее». Мягкое чтение обязано ей следовать: признак, копящийся // между вызовами, сделал бы заголовок функцией истории вызовов, а не тела. func TestParseПовторКлючаМеткиРешаетсяПоследнимЗначением(t *testing.T) { t.Parallel() body := `{"data":{"workouts":[{"id":"w1","start":123,"start":"2025-06-05 07:00:00 +0300"}]}}` res, err := hae.Parse([]byte(body), hae.Meta{}) if err != nil { t.Fatalf("разбор: %v", err) } if len(res.Workouts) != 1 { t.Fatalf("тренировок %d, ожидалась 1: валидная метка стоит последней", len(res.Workouts)) } if res.Workouts[0].Start.IsZero() { t.Error("метка не разобралась") } } // `"start": null` — это ключ, который пришёл. Фолбэк на `date` для него не // срабатывает: подстановка дала бы метку ДРУГОГО момента времени, неотличимую // от настоящей и ничем не считаемую. func TestParseПустойStartНеПодменяетсяПолемDate(t *testing.T) { t.Parallel() body := `{"data":{"workouts":[{"id":"w1","date":"2025-06-01 00:00:00 +0300",` + `"start":null,"end":"2025-06-05 07:10:00 +0300"}]}}` res, err := hae.Parse([]byte(body), hae.Meta{}) if err != nil { t.Fatalf("разбор: %v", err) } if len(res.Workouts) != 0 { t.Errorf("тренировка получила метку %v из чужого поля", res.Workouts[0].Start) } if res.SkippedEntityNoTime != 1 { t.Errorf("пропущено по метке %d, ожидалось 1", res.SkippedEntityNoTime) } }