добавлен словарь категориальных значений HAE → коды HealthKit
- фазы сна, контекст пульса и имена тренировок попадают в реестр `category_value` (миграция 00010): строка хранится дословно, выведенный код лежит рядом отдельной записью, а не полем внутри точки - словарь и синонимы кодов живут в бинаре (`internal/healthkit`); локаль из `Accept-Language` сужает поиск, но в ключ реестра не входит — заголовков в сыром архиве нет - наблюдение входит в отпечаток витрины, выведенный код — нет: он производная от словаря, а не от журнала
This commit is contained in:
@@ -0,0 +1,285 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user