- фазы сна, контекст пульса и имена тренировок попадают в реестр `category_value` (миграция 00010): строка хранится дословно, выведенный код лежит рядом отдельной записью, а не полем внутри точки - словарь и синонимы кодов живут в бинаре (`internal/healthkit`); локаль из `Accept-Language` сужает поиск, но в ключ реестра не входит — заголовков в сыром архиве нет - наблюдение входит в отпечаток витрины, выведенный код — нет: он производная от словаря, а не от журнала
286 lines
12 KiB
Go
286 lines
12 KiB
Go
package store_test
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.vakhrushev.me/av/healthlog/internal/store"
|
|
)
|
|
|
|
func category(metric, field, value, code string) store.CategoryValue {
|
|
return store.CategoryValue{Metric: metric, Field: field, Value: value, Code: code}
|
|
}
|
|
|
|
func mergeCategories(t *testing.T, st *store.Store, d store.DeliveryRef, vs ...store.CategoryValue) {
|
|
t.Helper()
|
|
|
|
if _, err := st.Merge(context.Background(), store.Incoming{Categories: vs}, d); err != nil {
|
|
t.Fatalf("слияние наблюдений: %v", err)
|
|
}
|
|
}
|
|
|
|
func categories(t *testing.T, st *store.Store) []store.CategoryValue {
|
|
t.Helper()
|
|
|
|
got, err := st.CategoryValues(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("чтение реестра: %v", err)
|
|
}
|
|
return got
|
|
}
|
|
|
|
func TestMergeCategoriesКладётНаблюдение(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
st := open(t)
|
|
d := from(t, "01AAA", "2026-08-03T10:00:00Z")
|
|
mergeCategories(t, st, d,
|
|
category("sleep_analysis", "value", "Во сне", "HKCategoryValueSleepAnalysisAsleepUnspecified"),
|
|
category("heart_rate", "context", "Не задано", ""))
|
|
|
|
got := categories(t, st)
|
|
if len(got) != 2 {
|
|
t.Fatalf("строк реестра %d, ожидалось 2: %+v", len(got), got)
|
|
}
|
|
// Порядок детерминирован ключом.
|
|
if got[0].Metric != "heart_rate" || got[1].Metric != "sleep_analysis" {
|
|
t.Errorf("порядок реестра не по ключу: %+v", got)
|
|
}
|
|
// Пустой код — законное состояние: словарь этой строки не знает.
|
|
if got[0].Code != "" {
|
|
t.Errorf("контекст пульса получил код %q", got[0].Code)
|
|
}
|
|
if got[1].Code != "HKCategoryValueSleepAnalysisAsleepUnspecified" {
|
|
t.Errorf("фаза сна получила код %q", got[1].Code)
|
|
}
|
|
if got[1].FirstDeliveryID != d.ID || !got[1].FirstSeen.Equal(d.ReceivedAt) {
|
|
t.Errorf("провенанс %s/%s, ожидался %s/%s",
|
|
got[1].FirstDeliveryID, got[1].FirstSeen, d.ID, d.ReceivedAt)
|
|
}
|
|
}
|
|
|
|
// Повторная свёртка той же доставки не меняет ни одной колонки: иначе состояние
|
|
// зависело бы от числа прогонов, а пересборка перестала бы быть no-op.
|
|
func TestMergeCategoriesПовторНичегоНеМеняет(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
st := open(t)
|
|
d := from(t, "01AAA", "2026-08-03T10:00:00Z")
|
|
v := category("sleep_analysis", "value", "Во сне", "HKCategoryValueSleepAnalysisAsleepUnspecified")
|
|
|
|
mergeCategories(t, st, d, v)
|
|
first := fingerprint(t, st)
|
|
before := categories(t, st)
|
|
|
|
mergeCategories(t, st, d, v)
|
|
if got := fingerprint(t, st); got != first {
|
|
t.Error("повторная свёртка сдвинула отпечаток")
|
|
}
|
|
after := categories(t, st)
|
|
if len(after) != len(before) || after[0] != before[0] {
|
|
t.Errorf("повтор изменил реестр: было %+v, стало %+v", before, after)
|
|
}
|
|
}
|
|
|
|
// Провенанс — МИНИМУМ по журналу, а не последняя запись: иначе проигрывание
|
|
// журнала давало бы не то, что живой приём, и порядок свёртки решал бы исход.
|
|
//
|
|
// Три доставки во всех шести порядках, а не пара: пара доказывает
|
|
// коммутативность и молчит про ассоциативность.
|
|
func TestMergeCategoriesПровенансНеЗависитОтПорядка(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
deliveries := []store.DeliveryRef{
|
|
from(t, "01AAA", "2026-08-01T10:00:00Z"),
|
|
from(t, "01BBB", "2026-08-02T10:00:00Z"),
|
|
// Та же секунда, что у предыдущей: порядок задаёт идентификатор, и
|
|
// сравнение по одной метке сделало бы исход зависящим от того, какая
|
|
// свернулась раньше.
|
|
from(t, "01AAB", "2026-08-02T10:00:00Z"),
|
|
}
|
|
v := category("sleep_analysis", "value", "Во сне", "HKCategoryValueSleepAnalysisAsleepUnspecified")
|
|
|
|
orders := [][]int{{0, 1, 2}, {0, 2, 1}, {1, 0, 2}, {1, 2, 0}, {2, 0, 1}, {2, 1, 0}}
|
|
var want string
|
|
for _, order := range orders {
|
|
t.Run(fmt.Sprint(order), func(t *testing.T) {
|
|
st := open(t)
|
|
for _, i := range order {
|
|
mergeCategories(t, st, deliveries[i], v)
|
|
}
|
|
got := categories(t, st)
|
|
if len(got) != 1 {
|
|
t.Fatalf("строк реестра %d, ожидалась одна", len(got))
|
|
}
|
|
if got[0].FirstDeliveryID != "01AAA" {
|
|
t.Errorf("провенанс %s, ожидалась самая ранняя доставка 01AAA", got[0].FirstDeliveryID)
|
|
}
|
|
// Отпечаток обязан совпасть у всех шести порядков.
|
|
fp := fingerprint(t, st)
|
|
if want == "" {
|
|
want = fp
|
|
} else if fp != want {
|
|
t.Errorf("порядок %v дал другой отпечаток", order)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Наблюдение в отпечаток входит, выведенный код — нет. Код производен от
|
|
// словаря в бинаре, а не от журнала: включённый в отпечаток, он заставил бы
|
|
// всякое пополнение словаря давать расхождение при совпавшем журнале.
|
|
func TestFingerprintРеестрБезКода(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
d := from(t, "01AAA", "2026-08-03T10:00:00Z")
|
|
|
|
withCode := open(t)
|
|
mergeCategories(t, withCode, d, category("sleep_analysis", "value", "Во сне", "HKCategoryValueSleepAnalysisAsleepUnspecified"))
|
|
|
|
otherCode := open(t)
|
|
mergeCategories(t, otherCode, d, category("sleep_analysis", "value", "Во сне", ""))
|
|
|
|
if fingerprint(t, withCode) != fingerprint(t, otherCode) {
|
|
t.Error("расхождение только по коду сдвинуло отпечаток — оракул сходимости стал функцией версии словаря")
|
|
}
|
|
|
|
otherValue := open(t)
|
|
mergeCategories(t, otherValue, d, category("sleep_analysis", "value", "Бодрствование", "HKCategoryValueSleepAnalysisAwake"))
|
|
if fingerprint(t, withCode) == fingerprint(t, otherValue) {
|
|
t.Error("разошедшееся наблюдение отпечаток не сдвинуло")
|
|
}
|
|
}
|
|
|
|
// Значение приходит из тела дословно и может содержать что угодно, включая
|
|
// признак раздела и разделители полей. Длина впереди каждого поля — то, что не
|
|
// даёт двум разным состояниям витрины сойтись отпечатком.
|
|
func TestFingerprintРазделительВЗначенииГраницуНеПодделывает(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
d := from(t, "01AAA", "2026-08-03T10:00:00Z")
|
|
|
|
a := open(t)
|
|
mergeCategories(t, a, d, category("sleep", "value|c|9", "x", ""))
|
|
|
|
b := open(t)
|
|
mergeCategories(t, b, d, category("sleep", "value", "|c|9|x", ""))
|
|
|
|
if fingerprint(t, a) == fingerprint(t, b) {
|
|
t.Error("разное разбиение тех же байтов по полям дало один отпечаток")
|
|
}
|
|
}
|
|
|
|
// Пустой реестр отпечаток не ломает: раздел, у которого нет строк, не пишет в
|
|
// хеш ничего.
|
|
func TestFingerprintПустойРеестр(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
empty := open(t)
|
|
ctx := context.Background()
|
|
|
|
fp, err := empty.Fingerprint(ctx)
|
|
if err != nil {
|
|
t.Fatalf("отпечаток пустой витрины: %v", err)
|
|
}
|
|
if fp == "" {
|
|
t.Error("отпечаток пустой витрины пуст")
|
|
}
|
|
n, err := empty.CountCategoryValues(ctx)
|
|
if err != nil {
|
|
t.Fatalf("счёт реестра: %v", err)
|
|
}
|
|
if n != 0 {
|
|
t.Errorf("строк реестра %d, ожидался ноль", n)
|
|
}
|
|
}
|
|
|
|
// Отказ слияния не оставляет строк реестра: доставка — единица свёртки, и
|
|
// частичное состояние повторная свёртка не чинит.
|
|
func TestMergeCategoriesОтказНеОставляетСтрок(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
st := open(t)
|
|
ctx := context.Background()
|
|
|
|
// Точка, содержимое которой не канонизируется, роняет всю транзакцию.
|
|
in := store.Incoming{
|
|
Points: []store.IncomingPoint{point(t, "m", "hour", "2026-08-03T10:00:00Z", "2026-08-03T10:00:00Z", `{{{`)},
|
|
Categories: []store.CategoryValue{category("sleep_analysis", "value", "Во сне", "код")},
|
|
}
|
|
if _, err := st.Merge(ctx, in, from(t, "01AAA", "2026-08-03T10:00:00Z")); err == nil {
|
|
t.Skip("слияние не отказало — проверять нечего")
|
|
}
|
|
if got := categories(t, st); len(got) != 0 {
|
|
t.Errorf("после отказа в реестре %d строк: %+v", len(got), got)
|
|
}
|
|
}
|
|
|
|
// Доставки приходят внахлёст, а провенанс реестра выбирается правилом
|
|
// «минимум по журналу» прямо в SQL. Правило, ни разу не исполненное
|
|
// конкурентно, проверено ровно наполовину: у точек такой прогон есть с самого
|
|
// начала (TestMergeКонкурентноеСлияниеНеТеряетТочки), у реестра его не было.
|
|
//
|
|
// Порядок горутин недетерминирован намеренно — в этом весь смысл: исход обязан
|
|
// быть функцией МНОЖЕСТВА доставок, а не того, кто первым добрался до
|
|
// транзакции.
|
|
func TestMergeCategoriesКонкурентноНеПортитПровенанс(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
st := open(t)
|
|
ctx := context.Background()
|
|
|
|
const writers = 8
|
|
const perWriter = 10
|
|
|
|
shared := category("sleep_analysis", "value", "Во сне", "HKCategoryValueSleepAnalysisAsleepUnspecified")
|
|
var wg sync.WaitGroup
|
|
errs := make(chan error, writers)
|
|
|
|
for w := range writers {
|
|
wg.Go(func() {
|
|
for i := range perWriter {
|
|
// Метка приёма растёт вместе с номером доставки: самая ранняя —
|
|
// у первой итерации первого писателя, и она обязана победить
|
|
// независимо от того, кто дошёл до базы раньше.
|
|
n := w*perWriter + i
|
|
d := store.DeliveryRef{
|
|
ID: fmt.Sprintf("d%03d", n),
|
|
ReceivedAt: ts(t, "2026-08-03T10:00:00Z").Add(time.Duration(n) * time.Second),
|
|
}
|
|
// Общий ключ у всех писателей плюс свой собственный: проверяется
|
|
// и спор за одну строку, и параллельная вставка разных.
|
|
own := category("heart_rate", "context", fmt.Sprintf("контекст-%03d", n), "")
|
|
if _, err := st.Merge(ctx, store.Incoming{Categories: []store.CategoryValue{shared, own}}, d); err != nil {
|
|
errs <- err
|
|
return
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
wg.Wait()
|
|
close(errs)
|
|
for err := range errs {
|
|
t.Fatalf("конкурентное слияние реестра: %v", err)
|
|
}
|
|
|
|
got := categories(t, st)
|
|
if len(got) != writers*perWriter+1 {
|
|
t.Fatalf("строк реестра %d, ожидалось %d", len(got), writers*perWriter+1)
|
|
}
|
|
for _, v := range got {
|
|
if v.Metric != "sleep_analysis" {
|
|
continue
|
|
}
|
|
if v.FirstDeliveryID != "d000" {
|
|
t.Errorf("провенанс общей строки %q, ожидалась самая ранняя доставка d000", v.FirstDeliveryID)
|
|
}
|
|
if !v.FirstSeen.Equal(ts(t, "2026-08-03T10:00:00Z")) {
|
|
t.Errorf("метка первой встречи %s, ожидалась 2026-08-03T10:00:00Z", v.FirstSeen)
|
|
}
|
|
}
|
|
}
|