Files
healthlog/internal/hae/entity_test.go
T
av 8331328134 Дозакрыты находки ревью по слиянию сущностей
- Правило покрытия получило второй разряд (условный, как у точек), запрет
  вырождения формы и счёт содержательных элементов ряда: скелет из скаляров и
  ряд из null больше не затирают маршрут. Победитель внутри доставки стал
  функцией множества версий — общим помощником с точками, — а провенанс
  поднимается и при совпавшем хеше, иначе отложенная доставка возвращала витрину
  к прежнему содержимому.
- Одно поле не того типа больше не уносит сущность, а пропуски видны в учётной
  записи доставки (миграция 00008, NULL = «не измерялось»); каноническая форма
  считается один раз и вне транзакции; откат бинаря поверх новой схемы отказывает
  на старте; текст ошибки разбора не несёт значений из тела.
- Ревью кода профилем deep (девять проходов) нашло две регрессии и обе закрыты:
  безусловный второй разряд запирал законный досчёт навсегда, а выбор победителя
  был квадратичен по числу присланных версий одного ключа.
2026-08-02 16:38:18 +03:00

421 lines
18 KiB
Go

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)
}
}