feat: разбор и хранение тренировок и состояния разума

- секции `workouts` и `stateOfMind` покрыты разбором: тренировка лежит одной
  строкой вместе с маршрутом и внутренними рядами, запись — по ключу `род + id`;
  миграция 00007 заводит обе таблицы и возвращает в очередь `partial`-доставки
  с этими ключами
- сущность заменяется целиком, но условно: приехавшая побеждает, если не теряет
  содержания сохранённой (множество ключей и длины верхнеуровневых массивов), а
  при равном содержании выигрывает версия из более поздней доставки ЖУРНАЛА —
  «побеждает приехавшая» было бы функцией порядка свёртки, и живая витрина
  расходилась бы с пересборкой молча
- отпечаток витрины покрывает тренировки и записи и снимается одним снимком
  базы; отчёт `reindex` считает «было и стало» по каждой единице хранения
This commit is contained in:
av
2026-08-02 13:05:16 +03:00
parent c28de9796e
commit f8200f7f80
47 changed files with 5817 additions and 301 deletions
+318
View File
@@ -0,0 +1,318 @@
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)
}
}