- секции `workouts` и `stateOfMind` покрыты разбором: тренировка лежит одной строкой вместе с маршрутом и внутренними рядами, запись — по ключу `род + id`; миграция 00007 заводит обе таблицы и возвращает в очередь `partial`-доставки с этими ключами - сущность заменяется целиком, но условно: приехавшая побеждает, если не теряет содержания сохранённой (множество ключей и длины верхнеуровневых массивов), а при равном содержании выигрывает версия из более поздней доставки ЖУРНАЛА — «побеждает приехавшая» было бы функцией порядка свёртки, и живая витрина расходилась бы с пересборкой молча - отпечаток витрины покрывает тренировки и записи и снимается одним снимком базы; отчёт `reindex` считает «было и стало» по каждой единице хранения
319 lines
13 KiB
Go
319 lines
13 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 длиннее предела — один счётчик на три
|
|
// случая: исход у них общий.
|
|
if res.SkippedNoID != 3 {
|
|
t.Errorf("пропущено по идентификатору %d, ожидалось 3", res.SkippedNoID)
|
|
}
|
|
// Метка не разбирается и метки нет вовсе.
|
|
if res.SkippedEntityNoTime != 2 {
|
|
t.Errorf("пропущено по метке %d, ожидалось 2", 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("исходное значение конца не сохранилось дословно")
|
|
}
|
|
})
|
|
|
|
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)
|
|
}
|
|
}
|