добавлен словарь категориальных значений HAE → коды HealthKit

- фазы сна, контекст пульса и имена тренировок попадают в реестр
  `category_value` (миграция 00010): строка хранится дословно, выведенный код
  лежит рядом отдельной записью, а не полем внутри точки
- словарь и синонимы кодов живут в бинаре (`internal/healthkit`); локаль из
  `Accept-Language` сужает поиск, но в ключ реестра не входит — заголовков в
  сыром архиве нет
- наблюдение входит в отпечаток витрины, выведенный код — нет: он производная
  от словаря, а не от журнала
This commit is contained in:
av
2026-08-04 07:32:19 +03:00
parent eb3fca77ee
commit 1b649ba3d5
40 changed files with 4317 additions and 42 deletions
+285
View File
@@ -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)
}
}
}