package store_test import ( "context" "encoding/json" "testing" "git.vakhrushev.me/av/healthlog/internal/store" ) // workout собирает тренировку с заданным содержимым. Заголовок в этих тестах // вторичен: правило замены смотрит на содержание, а не на колонки. func workout(t *testing.T, id, raw string) store.IncomingEntity { t.Helper() return store.IncomingEntity{ ID: id, Kind: "workouts", Name: "На улице Ходьба", Start: ts(t, "2025-06-05T07:00:00Z"), End: ts(t, "2025-06-05T07:10:00Z"), OffsetSeconds: 3 * 3600, Raw: json.RawMessage(raw), } } func from(t *testing.T, id, receivedAt string) store.DeliveryRef { t.Helper() return store.DeliveryRef{ID: id, ReceivedAt: ts(t, receivedAt)} } func mergeWorkouts(t *testing.T, st *store.Store, d store.DeliveryRef, ws ...store.IncomingEntity) store.MergeStats { t.Helper() stats, err := st.Merge(context.Background(), store.Incoming{Workouts: ws}, d) if err != nil { t.Fatalf("слияние сущностей: %v", err) } return stats } const ( // Содержимое подобрано так, чтобы отличаться от прежнего ЗНАЧЕНИЯМИ общих // полей: именно так тренировка и меняется между версиями (досчёт энергии), // и именно на этом ломается правило полноты, написанное для точек. woWithRoute = `{"id":"w1","name":"На улице Ходьба","route":[{"lat":1},{"lat":2},{"lat":3}], "activeEnergy":[{"qty":10}],"totalEnergy":{"qty":20,"units":"kJ"}}` woNoRouteNewValues = `{"id":"w1","name":"На улице Ходьба", "activeEnergy":[{"qty":11}],"totalEnergy":{"qty":21,"units":"kJ"}}` woShortRoute = `{"id":"w1","name":"На улице Ходьба","route":[{"lat":1}], "activeEnergy":[{"qty":11}],"totalEnergy":{"qty":21,"units":"kJ"}}` woRicher = `{"id":"w1","name":"На улице Ходьба","route":[{"lat":1},{"lat":2},{"lat":3}], "activeEnergy":[{"qty":11}],"totalEnergy":{"qty":21,"units":"kJ"},"stepCount":{"qty":900}}` woSameShapeNewValues = `{"id":"w1","name":"На улице Ходьба","route":[{"lat":1},{"lat":2},{"lat":3}], "activeEnergy":[{"qty":11}],"totalEnergy":{"qty":21,"units":"kJ"}}` woIncomparable = `{"id":"w1","name":"На улице Ходьба", "activeEnergy":[{"qty":11}],"totalEnergy":{"qty":21,"units":"kJ"},"flightsClimbed":{"qty":3}}` ) func storedRaw(t *testing.T, st *store.Store, id string) string { t.Helper() w, err := st.Workout(context.Background(), id) if err != nil { t.Fatalf("чтение тренировки: %v", err) } return string(w.Raw) } // Единственная причина повторной присылки тренировки — доезжающий маршрут. func TestMergeДоехавшийМаршрутЗамещаетТренировку(t *testing.T) { t.Parallel() st := open(t) mergeWorkouts(t, st, from(t, "d1", "2025-06-05T08:00:00Z"), workout(t, "w1", woNoRouteNewValues)) stats := mergeWorkouts(t, st, from(t, "d2", "2025-06-05T08:05:00Z"), workout(t, "w1", woWithRoute)) if stats.WorkoutsWritten != 1 { t.Errorf("записано %d, ожидалась 1: версия с маршрутом полнее", stats.WorkoutsWritten) } if stats.EntitiesHeld != 0 { t.Errorf("удержано %d, ожидалось 0", stats.EntitiesHeld) } if got := storedRaw(t, st, "w1"); got != woWithRoute { t.Error("в витрине не версия с маршрутом") } } // Тренировка досчитывается задним числом ровно так же, как минутное ведро: // набор полей тот же, значения новые. Тай-брейк по канонической форме // заморозил бы её на произвольной версии навсегда. func TestMergeДосчётПриТомЖеНабореПолейПобеждает(t *testing.T) { t.Parallel() st := open(t) mergeWorkouts(t, st, from(t, "d1", "2025-06-05T08:00:00Z"), workout(t, "w1", woWithRoute)) stats := mergeWorkouts(t, st, from(t, "d2", "2025-06-05T08:05:00Z"), workout(t, "w1", woSameShapeNewValues)) if stats.WorkoutsWritten != 1 { t.Errorf("записано %d, ожидалась 1", stats.WorkoutsWritten) } if got := storedRaw(t, st, "w1"); got != woSameShapeNewValues { t.Error("досчитанная версия не легла в витрину") } } // Исход обязан быть функцией ЖУРНАЛА, а не порядка свёртки: воркер сворачивает // в порядке журнала только среди видимых ему доставок, и доставка с более // ранней меткой может свернуться позже. func TestMergeВерсияИзБолееРаннейДоставкиНеОткатываетВитрину(t *testing.T) { t.Parallel() early := from(t, "d1", "2025-06-05T08:00:00Z") late := from(t, "d2", "2025-06-05T08:05:00Z") прямой := open(t) mergeWorkouts(t, прямой, early, workout(t, "w1", woWithRoute)) mergeWorkouts(t, прямой, late, workout(t, "w1", woSameShapeNewValues)) обратный := open(t) mergeWorkouts(t, обратный, late, workout(t, "w1", woSameShapeNewValues)) mergeWorkouts(t, обратный, early, workout(t, "w1", woWithRoute)) a, b := storedRaw(t, прямой, "w1"), storedRaw(t, обратный, "w1") if a != b { t.Error("исход зависит от порядка свёртки — живая витрина разойдётся с пересборкой") } if a != woSameShapeNewValues { t.Error("победила версия не из более поздней доставки журнала") } } // Маршрут — 95% содержимого тренировки, а восстановление требует пересборки // всего журнала. Событие делается наблюдаемым, а не необратимым. func TestMergeОбеднённаяВерсияНеЗатираетСохранённую(t *testing.T) { t.Parallel() st := open(t) mergeWorkouts(t, st, from(t, "d1", "2025-06-05T08:00:00Z"), workout(t, "w1", woWithRoute)) stats := mergeWorkouts(t, st, from(t, "d2", "2025-06-05T08:05:00Z"), workout(t, "w1", woNoRouteNewValues)) if stats.WorkoutsWritten != 0 { t.Errorf("записано %d, ожидалось 0: приехавшая теряет маршрут", stats.WorkoutsWritten) } if stats.EntitiesHeld != 1 { t.Errorf("удержано %d, ожидалась 1 — событие обязано быть видно", stats.EntitiesHeld) } if len(stats.HeldAt) != 1 || stats.HeldAt[0].ID != "w1" { t.Errorf("координаты удержанной версии %v", stats.HeldAt) } if got := storedRaw(t, st, "w1"); got != woWithRoute { t.Error("маршрут пропал из витрины") } } // Усечённый маршрут ключа не теряет — множеств ключей мало. func TestMergeУсечённыйМаршрутНеЗатираетСохранённый(t *testing.T) { t.Parallel() st := open(t) mergeWorkouts(t, st, from(t, "d1", "2025-06-05T08:00:00Z"), workout(t, "w1", woWithRoute)) stats := mergeWorkouts(t, st, from(t, "d2", "2025-06-05T08:05:00Z"), workout(t, "w1", woShortRoute)) if stats.EntitiesHeld != 1 { t.Errorf("удержано %d, ожидалась 1", stats.EntitiesHeld) } if got := storedRaw(t, st, "w1"); got != woWithRoute { t.Error("полный маршрут вытеснен усечённым") } } // Несравнимые наборы: у каждой версии есть содержание, которого нет у другой. // Поля не объединяются, вместо этого — счётчик. func TestMergeНесравнимыеНаборыНеОбъединяются(t *testing.T) { t.Parallel() st := open(t) mergeWorkouts(t, st, from(t, "d1", "2025-06-05T08:00:00Z"), workout(t, "w1", woWithRoute)) stats := mergeWorkouts(t, st, from(t, "d2", "2025-06-05T08:05:00Z"), workout(t, "w1", woIncomparable)) if stats.EntitiesHeld != 1 { t.Errorf("удержано %d, ожидалась 1", stats.EntitiesHeld) } if got := storedRaw(t, st, "w1"); got != woWithRoute { t.Error("несравнимая версия заместила сохранённую") } } // Хеш — детектор изменений: тренировка переприсылается каждой доставкой, пока // не доедет маршрут, и 41 копия из 44 записи вызывать не должна. func TestMergeПовторТойЖеТренировкиНеПишет(t *testing.T) { t.Parallel() st := open(t) mergeWorkouts(t, st, from(t, "d1", "2025-06-05T08:00:00Z"), workout(t, "w1", woWithRoute)) // Тот же смысл, другой порядок ключей и дребезг литерала: каноническая // форма обязана совпасть. same := `{"name":"На улице Ходьба","id":"w1","totalEnergy":{"units":"kJ","qty":20.0}, "activeEnergy":[{"qty":10}],"route":[{"lat":1},{"lat":2},{"lat":3}]}` stats := mergeWorkouts(t, st, from(t, "d2", "2025-06-05T08:05:00Z"), workout(t, "w1", same)) if stats.WorkoutsWritten != 0 { t.Errorf("записано %d, ожидалось 0: содержимое то же", stats.WorkoutsWritten) } } // Три версии в разных порядках подачи: пункты правила, не зависящие от порядка // свёртки, обязаны давать одно состояние. Конвенция требует перестановки трёх, // а не пары: попарная свёртка уже давала нетранзитивную победу на точках. func TestMergeПерестановкаТрёхВерсийДаётОдноСостояние(t *testing.T) { t.Parallel() type step struct { d store.DeliveryRef raw string } steps := []step{ {from(t, "d1", "2025-06-05T08:00:00Z"), woNoRouteNewValues}, {from(t, "d2", "2025-06-05T08:05:00Z"), woWithRoute}, {from(t, "d3", "2025-06-05T08:10:00Z"), woRicher}, } orders := [][]int{{0, 1, 2}, {2, 1, 0}, {1, 0, 2}, {1, 2, 0}, {2, 0, 1}, {0, 2, 1}} var want string for i, order := range orders { st := open(t) for _, idx := range order { mergeWorkouts(t, st, steps[idx].d, workout(t, "w1", steps[idx].raw)) } got := storedRaw(t, st, "w1") if i == 0 { want = got if want != woRicher { t.Fatalf("победила не самая полная версия") } continue } if got != want { t.Errorf("порядок %v дал другое состояние", order) } } } // Порядок элементов в JSON-массиве нестабилен, поэтому две версии одного ключа // внутри одной доставки не имеют права разрешаться «последним в массиве». func TestMergeДвеВерсииВОдномТелеНеЗависятОтПорядка(t *testing.T) { t.Parallel() d := from(t, "d1", "2025-06-05T08:00:00Z") прямой := open(t) mergeWorkouts(t, прямой, d, workout(t, "w1", woWithRoute), workout(t, "w1", woSameShapeNewValues)) обратный := open(t) mergeWorkouts(t, обратный, d, workout(t, "w1", woSameShapeNewValues), workout(t, "w1", woWithRoute)) if storedRaw(t, прямой, "w1") != storedRaw(t, обратный, "w1") { t.Error("исход зависит от порядка элементов в массиве секции") } } // Записи разных родов с одним идентификатором — разные записи: ключ пара, а не // один id. func TestMergeЗаписиРазныхРодовСОднимIDНеСталкиваются(t *testing.T) { t.Parallel() st := open(t) ctx := context.Background() mind := store.IncomingEntity{ ID: "e1", Kind: "stateOfMind", Start: ts(t, "2025-06-05T18:00:00Z"), End: ts(t, "2025-06-05T18:00:00Z"), Raw: json.RawMessage(`{"id":"e1","kind":"momentary_emotion","valence":0.5}`), } ecg := store.IncomingEntity{ ID: "e1", Kind: "ecg", Start: ts(t, "2025-06-05T19:00:00Z"), End: ts(t, "2025-06-05T19:00:00Z"), Raw: json.RawMessage(`{"id":"e1","classification":"sinusRhythm"}`), } if _, err := st.Merge(ctx, store.Incoming{Records: []store.IncomingEntity{mind, ecg}}, from(t, "d1", "2025-06-05T20:00:00Z")); err != nil { t.Fatalf("слияние записей: %v", err) } for _, kind := range []string{"stateOfMind", "ecg"} { if _, err := st.Record(ctx, kind, "e1"); err != nil { t.Errorf("запись рода %q не найдена: %v", kind, err) } } } // Отпечаток — единственный оракул сходимости. Витрины, совпадающие по часовым // объектам, но разошедшиеся в тренировке, обязаны давать разные отпечатки. func TestFingerprintРазличаетТренировки(t *testing.T) { t.Parallel() ctx := context.Background() build := func(raw string) string { st := open(t) in := store.Incoming{ Points: []store.IncomingPoint{point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":1}`)}, Workouts: []store.IncomingEntity{workout(t, "w1", raw)}, } if _, err := st.Merge(ctx, in, from(t, "d1", "2025-06-05T08:00:00Z")); err != nil { t.Fatalf("слияние: %v", err) } fp, err := st.Fingerprint(ctx) if err != nil { t.Fatalf("отпечаток: %v", err) } return fp } full, changed := build(woWithRoute), build(woSameShapeNewValues) if full == changed { t.Error("отпечатки совпали при разошедшемся содержимом тренировки") } if again := build(woWithRoute); again != full { t.Error("отпечаток не воспроизводится на одном содержимом") } } // Длительность: ноль — законное измерение, а «не прислали» обязано быть // отличимо от него. func TestWorkoutДлительностьОтличаетНольОтОтсутствия(t *testing.T) { t.Parallel() st := open(t) ctx := context.Background() zero := 0.0 withZero := workout(t, "w-zero", `{"id":"w-zero","qty":1}`) withZero.Duration = &zero withNone := workout(t, "w-none", `{"id":"w-none","qty":1}`) if _, err := st.Merge(ctx, store.Incoming{Workouts: []store.IncomingEntity{withZero, withNone}}, from(t, "d1", "2025-06-05T08:00:00Z")); err != nil { t.Fatalf("слияние: %v", err) } w, err := st.Workout(ctx, "w-zero") if err != nil { t.Fatalf("чтение: %v", err) } if w.Duration == nil || *w.Duration != 0 { t.Errorf("нулевая длительность потерялась: %v", w.Duration) } w, err = st.Workout(ctx, "w-none") if err != nil { t.Fatalf("чтение: %v", err) } if w.Duration != nil { t.Errorf("отсутствие длительности стало значением %v", *w.Duration) } } // Содержимое сущности хранится дословно: побайтовый круг «запись → чтение». func TestWorkoutСодержимоеХранитсяДословно(t *testing.T) { t.Parallel() st := open(t) // Литералы, которые теряет любая пересборка через разобранные значения: // `1.0`, целое больше 2^53, дробь длиннее двенадцати значащих цифр, а также // символы, которые json.Marshal экранирует по умолчанию. const raw = `{"id":"w9","route":[{"lat":1.0,"big":9007199254740993,"p":0.123456789012345678}], "note":"ad","isIndoor":false}` if _, err := st.Merge(context.Background(), store.Incoming{Workouts: []store.IncomingEntity{workout(t, "w9", raw)}}, from(t, "d1", "2025-06-05T08:00:00Z")); err != nil { t.Fatalf("слияние: %v", err) } if got := storedRaw(t, st, "w9"); got != raw { t.Errorf("содержимое изменилось при хранении:\nбыло %s\nстало %s", raw, got) } } // Провенанс нужен не отчётности: по нему разрешается тай-брейк, и без него // запись WARN об удержанной версии не связать с телом в архиве. func TestWorkoutНесётПровенанс(t *testing.T) { t.Parallel() st := open(t) mergeWorkouts(t, st, from(t, "d7", "2025-06-05T08:00:00Z"), workout(t, "w1", woWithRoute)) w, err := st.Workout(context.Background(), "w1") if err != nil { t.Fatalf("чтение: %v", err) } if w.Delivery != "d7" { t.Errorf("провенанс %q, ожидался d7", w.Delivery) } if w.Start.IsZero() || w.End.Before(w.Start) { t.Errorf("интервал заголовка неверен: %v — %v", w.Start, w.End) } if w.OffsetSeconds != 3*3600 { t.Errorf("офсет %d, ожидался 10800", w.OffsetSeconds) } } // Внутри одной доставки «сохранённой» версии не существует — есть только // порядок элементов в JSON-массиве, а он нестабилен. Ни исход, ни счётчик не // имеют права от него зависеть. func TestMergeНесравнимыеВерсииВОдномТелеНеЗависятОтПорядка(t *testing.T) { t.Parallel() d := from(t, "d1", "2025-06-05T08:00:00Z") const withRoute = `{"id":"w1","route":[{"lat":1}],"stepCount":{"qty":9}}` const withFlights = `{"id":"w1","route":[{"lat":1}],"flightsClimbed":{"qty":3}}` прямой := open(t) a := mergeWorkouts(t, прямой, d, workout(t, "w1", withRoute), workout(t, "w1", withFlights)) обратный := open(t) b := mergeWorkouts(t, обратный, d, workout(t, "w1", withFlights), workout(t, "w1", withRoute)) if storedRaw(t, прямой, "w1") != storedRaw(t, обратный, "w1") { t.Error("исход зависит от порядка элементов в массиве секции") } if a.EntitiesHeld != b.EntitiesHeld { t.Errorf("счётчик зависит от порядка: %d против %d", a.EntitiesHeld, b.EntitiesHeld) } if a.EntitiesHeld == 0 { t.Error("две версии с разным содержанием в одном теле остались незамеченными") } } // Отпечаток обязан различать состояния, а не только содержимое: составной ключ // записи, склеенный до взятия длины, даёт (`a`, `b/c`) = (`a/b`, `c`). func TestFingerprintРазличаетСоставнойКлючЗаписи(t *testing.T) { t.Parallel() ctx := context.Background() build := func(kind, id string) string { st := open(t) rec := store.IncomingEntity{ ID: id, Kind: kind, Start: ts(t, "2025-06-05T18:00:00Z"), End: ts(t, "2025-06-05T18:00:00Z"), Raw: json.RawMessage(`{"id":"x","valence":0.5}`), } if _, err := st.Merge(ctx, store.Incoming{Records: []store.IncomingEntity{rec}}, from(t, "d1", "2025-06-05T08:00:00Z")); err != nil { t.Fatalf("слияние: %v", err) } fp, err := st.Fingerprint(ctx) if err != nil { t.Fatalf("отпечаток: %v", err) } return fp } if build("a", "b/c") == build("a/b", "c") { t.Error("два разных состояния витрины дали один отпечаток: составной ключ склеен до взятия длины") } }