добавлен словарь категориальных значений HAE → коды HealthKit
- фазы сна, контекст пульса и имена тренировок попадают в реестр `category_value` (миграция 00010): строка хранится дословно, выведенный код лежит рядом отдельной записью, а не полем внутри точки - словарь и синонимы кодов живут в бинаре (`internal/healthkit`); локаль из `Accept-Language` сужает поиск, но в ключ реестра не входит — заголовков в сыром архиве нет - наблюдение входит в отпечаток витрины, выведенный код — нет: он производная от словаря, а не от журнала
This commit is contained in:
@@ -0,0 +1,277 @@
|
||||
package fold_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.vakhrushev.me/av/healthlog/internal/archive"
|
||||
"git.vakhrushev.me/av/healthlog/internal/fold"
|
||||
"git.vakhrushev.me/av/healthlog/internal/store"
|
||||
)
|
||||
|
||||
// Тело с обеими наблюдаемыми формами: фаза сна, которую словарь знает, и
|
||||
// контекст пульса, которого он не знает.
|
||||
const categoricalBody = `{"data":{"metrics":[` +
|
||||
`{"name":"sleep_analysis","units":"hr","data":[` +
|
||||
`{"date":"2025-06-05 10:00:00 +0300","start":"2025-06-05 10:00:00 +0300",` +
|
||||
`"end":"2025-06-05 11:00:00 +0300","qty":1,"value":"Во сне"}]},` +
|
||||
`{"name":"heart_rate","units":"count/min","data":[` +
|
||||
`{"date":"2025-06-05 10:00:00 +0300","Avg":62,"context":"Сидячий образ жизни"}]}` +
|
||||
`]}}`
|
||||
|
||||
// receivedAt — фиксированная метка приёма.
|
||||
//
|
||||
// Не `store.Now()`: провенанс первой встречи входит в отпечаток витрины, и
|
||||
// метка от часов сделала бы сравнение отпечатков между подтестами функцией
|
||||
// того, в одну ли секунду они успели отработать. Ровно так этот тест и покраснел
|
||||
// на гейте — у трёх подтестов из четырёх метки совпали, у четвёртого нет.
|
||||
var receivedAt = time.Date(2025, 6, 5, 7, 0, 0, 0, time.UTC)
|
||||
|
||||
func deliverWithHeaders(t *testing.T, arch *archive.Archive, st *store.Store, id, headers string, body []byte) {
|
||||
t.Helper()
|
||||
|
||||
at := receivedAt
|
||||
rawPath, err := arch.Write(id, at, body)
|
||||
if err != nil {
|
||||
t.Fatalf("запись в архив: %v", err)
|
||||
}
|
||||
err = st.CreateDelivery(context.Background(), store.Delivery{
|
||||
ID: id,
|
||||
ReceivedAt: at,
|
||||
AutomationID: "auto-1",
|
||||
Aggregation: "Hours",
|
||||
Bytes: int64(len(body)),
|
||||
SHA256: "-",
|
||||
RawPath: rawPath,
|
||||
Headers: headers,
|
||||
ParseStatus: store.ParsePending,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("запись доставки: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func foldService(t *testing.T, log *slog.Logger) (*fold.Service, *archive.Archive, *store.Store) {
|
||||
t.Helper()
|
||||
|
||||
dir := t.TempDir()
|
||||
arch, err := archive.New(filepath.Join(dir, "raw"))
|
||||
if err != nil {
|
||||
t.Fatalf("архив: %v", err)
|
||||
}
|
||||
st, err := store.Open(filepath.Join(dir, "healthlog.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("база: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
return fold.New(arch, st, 0, log), arch, st
|
||||
}
|
||||
|
||||
func TestFoldКладётНаблюденияВРеестр(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, arch, st := foldService(t, slog.New(slog.DiscardHandler))
|
||||
deliverWithHeaders(t, arch, st, "d1", `{"Accept-Language":["ru"]}`, []byte(categoricalBody))
|
||||
|
||||
stats, err := f.Fold(context.Background(), "d1")
|
||||
if err != nil {
|
||||
t.Fatalf("свёртка: %v", err)
|
||||
}
|
||||
if stats.Categoricals != 2 {
|
||||
t.Errorf("наблюдений %d, ожидалось 2", stats.Categoricals)
|
||||
}
|
||||
if stats.CategoricalUnknown != 1 {
|
||||
t.Errorf("строк без кода %d, ожидалась 1 (контекст пульса)", stats.CategoricalUnknown)
|
||||
}
|
||||
|
||||
got, err := st.CategoryValues(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("чтение реестра: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("строк реестра %d: %+v", len(got), got)
|
||||
}
|
||||
if got[1].Value != "Во сне" || got[1].Code != "HKCategoryValueSleepAnalysisAsleepUnspecified" {
|
||||
t.Errorf("фаза сна в реестре: %+v", got[1])
|
||||
}
|
||||
if got[0].Value != "Сидячий образ жизни" || got[0].Code != "" {
|
||||
t.Errorf("контекст пульса в реестре: %+v", got[0])
|
||||
}
|
||||
if got[1].FirstDeliveryID != "d1" {
|
||||
t.Errorf("провенанс %q, ожидался d1", got[1].FirstDeliveryID)
|
||||
}
|
||||
}
|
||||
|
||||
// Локаль сужает поиск по словарю, но в ключ не входит и вывода не отменяет:
|
||||
// заголовков в сыром архиве нет, и доставка, восстановленная из осиротевшего
|
||||
// тела, обязана дать то же состояние.
|
||||
func TestFoldЛокальНеМеняетСостояния(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct{ name, headers string }{
|
||||
{"измеренный заголовок потока", `{"Accept-Language":["ru"]}`},
|
||||
{"подтег, веса и регистр", `{"accept-language":["RU-ru,ru;q=0.9,en;q=0.8"]}`},
|
||||
{"заголовка нет вовсе", `{}`},
|
||||
{"незнакомая локаль", `{"Accept-Language":["de"]}`},
|
||||
// Пустая строка и битый JSON — не вычурность: заголовков в сыром архиве
|
||||
// нет вовсе, и доставка, восстановленная из осиротевшего тела, приезжает
|
||||
// ровно так. Оба обязаны дать пустую локаль и то же состояние.
|
||||
{"заголовков нет вовсе", ``},
|
||||
{"заголовки не разбираются", `не json`},
|
||||
{"заголовок пустым списком", `{"Accept-Language":[]}`},
|
||||
}
|
||||
|
||||
var want string
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
f, arch, st := foldService(t, slog.New(slog.DiscardHandler))
|
||||
deliverWithHeaders(t, arch, st, "d1", c.headers, []byte(categoricalBody))
|
||||
if _, err := f.Fold(context.Background(), "d1"); err != nil {
|
||||
t.Fatalf("свёртка: %v", err)
|
||||
}
|
||||
got, err := st.CategoryValues(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("чтение реестра: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("строк реестра %d: %+v", len(got), got)
|
||||
}
|
||||
if got[1].Code != "HKCategoryValueSleepAnalysisAsleepUnspecified" {
|
||||
t.Errorf("код фазы сна %q — локаль отменила вывод", got[1].Code)
|
||||
}
|
||||
// Отпечаток не должен зависеть от заголовка вовсе: ключ реестра —
|
||||
// функция одних тел.
|
||||
fp, err := st.Fingerprint(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("отпечаток: %v", err)
|
||||
}
|
||||
if want == "" {
|
||||
want = fp
|
||||
} else if fp != want {
|
||||
t.Errorf("заголовок %s сдвинул отпечаток витрины", c.headers)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Строки категориальных значений — данные о здоровье наравне со значением
|
||||
// точки: «Сидячий образ жизни» описывает человека. В журнал уходят только
|
||||
// счётчики.
|
||||
//
|
||||
// Запись РАЗБИРАЕТСЯ, служебное `time` выбрасывается, и поиск идёт в остатке:
|
||||
// метка времени содержит произвольные цифры, и поиск по сырому буферу делает
|
||||
// такой тест флаки по построению (запись 2026-08-02 в docs/review.md).
|
||||
func TestFoldНеПишетКатегориальныхСтрокВЛог(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var buf bytes.Buffer
|
||||
log := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||
|
||||
f, arch, st := foldService(t, log)
|
||||
deliverWithHeaders(t, arch, st, "d1", `{"Accept-Language":["ru"]}`, []byte(categoricalBody))
|
||||
if _, err := f.Fold(context.Background(), "d1"); err != nil {
|
||||
t.Fatalf("свёртка: %v", err)
|
||||
}
|
||||
|
||||
logged := strings.TrimSpace(buf.String())
|
||||
if logged == "" {
|
||||
t.Fatal("свёртка не записала ни одной строки — чекпоинт молчит")
|
||||
}
|
||||
|
||||
var folded map[string]any
|
||||
for line := range strings.SplitSeq(logged, "\n") {
|
||||
var rec map[string]any
|
||||
if err := json.Unmarshal([]byte(line), &rec); err != nil {
|
||||
t.Fatalf("строка лога не JSON: %v", err)
|
||||
}
|
||||
delete(rec, "time")
|
||||
clean, err := json.Marshal(rec)
|
||||
if err != nil {
|
||||
t.Fatalf("запись лога не сериализуется: %v", err)
|
||||
}
|
||||
for _, secret := range []string{"Во сне", "Сидячий образ жизни", "HKCategoryValue"} {
|
||||
if strings.Contains(string(clean), secret) {
|
||||
t.Errorf("в логе оказалось %q:\n%s", secret, clean)
|
||||
}
|
||||
}
|
||||
if msg, _ := rec["msg"].(string); strings.HasPrefix(msg, "delivery folded") {
|
||||
folded = rec
|
||||
}
|
||||
}
|
||||
|
||||
if folded == nil {
|
||||
t.Fatalf("записи об исходе свёртки нет:\n%s", logged)
|
||||
}
|
||||
for _, attr := range []string{"categoricals", "categoricals_unknown", "categoricals_dropped"} {
|
||||
if _, ok := folded[attr]; !ok {
|
||||
t.Errorf("в записи нет счётчика %q: %v", attr, folded)
|
||||
}
|
||||
}
|
||||
if folded["categoricals_unknown"] != float64(1) {
|
||||
t.Errorf("счётчик строк без кода %v, ожидалась 1", folded["categoricals_unknown"])
|
||||
}
|
||||
}
|
||||
|
||||
// Срабатывание границы наблюдений обязано подниматься до WARN: границы
|
||||
// подобраны по измерению, поэтому попадание в них — аномалия, а не режим, и в
|
||||
// INFO оно утонуло бы среди штатных доставок раз в пять минут.
|
||||
func TestFoldПревышениеГраницыДаётWarn(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var buf bytes.Buffer
|
||||
log := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||
|
||||
f, arch, st := foldService(t, log)
|
||||
|
||||
// Одно непомерно длинное значение — граница длины, а не числа: тело
|
||||
// остаётся маленьким, а событие то же.
|
||||
long := strings.Repeat("я", 200)
|
||||
body := `{"data":{"metrics":[{"name":"sleep_analysis","units":"hr","data":[` +
|
||||
`{"date":"2025-06-05 10:00:00 +0300","start":"2025-06-05 10:00:00 +0300",` +
|
||||
`"end":"2025-06-05 11:00:00 +0300","qty":1,"value":"` + long + `"}]}]}}`
|
||||
deliverWithHeaders(t, arch, st, "d1", `{"Accept-Language":["ru"]}`, []byte(body))
|
||||
|
||||
stats, err := f.Fold(context.Background(), "d1")
|
||||
if err != nil {
|
||||
t.Fatalf("свёртка: %v", err)
|
||||
}
|
||||
if stats.CategoricalDropped == 0 {
|
||||
t.Fatal("граница не сработала — тест проверяет не то")
|
||||
}
|
||||
|
||||
var rec map[string]any
|
||||
for line := range strings.SplitSeq(strings.TrimSpace(buf.String()), "\n") {
|
||||
var r map[string]any
|
||||
if err := json.Unmarshal([]byte(line), &r); err != nil {
|
||||
t.Fatalf("строка лога не JSON: %v", err)
|
||||
}
|
||||
if msg, _ := r["msg"].(string); strings.HasPrefix(msg, "delivery folded") {
|
||||
rec = r
|
||||
}
|
||||
}
|
||||
if rec == nil {
|
||||
t.Fatalf("записи об исходе свёртки нет:\n%s", buf.String())
|
||||
}
|
||||
if rec["level"] != "WARN" {
|
||||
t.Errorf("уровень %v, ожидался WARN:\n%s", rec["level"], buf.String())
|
||||
}
|
||||
if rec["msg"] != "delivery folded, categorical values dropped by limit" {
|
||||
t.Errorf("сообщение %v не называет событие", rec["msg"])
|
||||
}
|
||||
|
||||
// И ни одного байта самой строки: она из тела доставки.
|
||||
delete(rec, "time")
|
||||
clean, err := json.Marshal(rec)
|
||||
if err != nil {
|
||||
t.Fatalf("запись лога не сериализуется: %v", err)
|
||||
}
|
||||
if strings.Contains(string(clean), "яяя") {
|
||||
t.Errorf("в логе оказалось значение из тела:\n%s", clean)
|
||||
}
|
||||
}
|
||||
+86
-3
@@ -9,6 +9,7 @@ package fold
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -18,6 +19,7 @@ import (
|
||||
|
||||
"git.vakhrushev.me/av/healthlog/internal/archive"
|
||||
"git.vakhrushev.me/av/healthlog/internal/hae"
|
||||
"git.vakhrushev.me/av/healthlog/internal/healthkit"
|
||||
"git.vakhrushev.me/av/healthlog/internal/store"
|
||||
)
|
||||
|
||||
@@ -90,6 +92,16 @@ type Stats struct {
|
||||
Uncovered []string
|
||||
// UncoveredDropped — сколько имён отброшено границей списка.
|
||||
UncoveredDropped int
|
||||
|
||||
// Categoricals — сколько РАЗЛИЧНЫХ категориальных значений наблюдалось;
|
||||
// CategoricalUnknown — сколько из них словарь не знает;
|
||||
// CategoricalDropped — сколько отброшено границами разбора.
|
||||
//
|
||||
// Только числа: сами строки — данные о здоровье, контекст пульса и фаза сна
|
||||
// описывают человека не меньше, чем число.
|
||||
Categoricals int
|
||||
CategoricalUnknown int
|
||||
CategoricalDropped int
|
||||
}
|
||||
|
||||
// ErrPanicked — свёртка паниковала. Доставка получает `failed`: тело в архиве, и
|
||||
@@ -144,6 +156,7 @@ func (s *Service) Fold(ctx context.Context, deliveryID string) (stats Stats, err
|
||||
parsed, err := hae.Parse(body, hae.Meta{
|
||||
Aggregation: d.Aggregation,
|
||||
FallbackLayer: hae.Layer(fallback),
|
||||
Locale: localeOf(d.Headers),
|
||||
})
|
||||
if err != nil {
|
||||
// Список непокрытых секций переживает отказ: доставка, у которой не
|
||||
@@ -169,6 +182,9 @@ func (s *Service) Fold(ctx context.Context, deliveryID string) (stats Stats, err
|
||||
stats.SkippedEntityMalformed = parsed.SkippedEntityMalformed
|
||||
stats.Layer = string(parsed.Layer)
|
||||
stats.LayerMismatch = parsed.LayerMismatch
|
||||
stats.Categoricals = len(parsed.Categoricals)
|
||||
stats.CategoricalUnknown = parsed.CategoricalUnknown
|
||||
stats.CategoricalDropped = parsed.CategoricalDropped
|
||||
|
||||
merge, err := s.store.Merge(ctx, toIncoming(parsed), store.DeliveryRef{
|
||||
ID: d.ID,
|
||||
@@ -256,6 +272,12 @@ func (s *Service) logResult(ctx context.Context, deliveryID string, st Stats) {
|
||||
// построчный разбор логов. Содержимого секций здесь нет.
|
||||
"uncovered", st.Uncovered,
|
||||
"uncovered_dropped", st.UncoveredDropped,
|
||||
// Категориальные значения — ЧИСЛАМИ. Ни строк, ни выведенных кодов:
|
||||
// «Сидячий образ жизни» — это контекст пульса, то есть данные о
|
||||
// здоровье. Какие именно строки ждут словаря, отвечает реестр в базе.
|
||||
"categoricals", st.Categoricals,
|
||||
"categoricals_unknown", st.CategoricalUnknown,
|
||||
"categoricals_dropped", st.CategoricalDropped,
|
||||
}
|
||||
if len(st.Collisions) > 0 {
|
||||
attrs = append(attrs, "collisions", formatCollisions(st.Collisions))
|
||||
@@ -298,6 +320,19 @@ func (s *Service) logResult(ctx context.Context, deliveryID string, st Stats) {
|
||||
// Не частичный разбор, а тело, не похожее на HAE: секций у HAE восемь,
|
||||
// а границу выбило больше тридцати двух.
|
||||
s.log.WarnContext(ctx, "delivery folded, uncovered section list truncated", attrs...)
|
||||
case st.CategoricalDropped > 0:
|
||||
// Тот же класс события и та же причина эскалации, что у списка секций:
|
||||
// границы подобраны по измерению (на живом потоке около одиннадцати
|
||||
// различных значений, самое длинное 36 байт), поэтому попадание в них —
|
||||
// уже аномалия, а не режим. В INFO оно утонуло бы: поток идёт раз в пять
|
||||
// минут, а `/stats` ещё нет. Соответствие «доставка → отброшено» живёт
|
||||
// только здесь: лог ротируется, и к следующей сверке отпечатков причину
|
||||
// уже не восстановить.
|
||||
//
|
||||
// `CategoricalUnknown` намеренно НЕ эскалируется: он штатно ненулевой —
|
||||
// словарь покрывает только фазы сна, — и постоянный WARN обесценил бы
|
||||
// уровень.
|
||||
s.log.WarnContext(ctx, "delivery folded, categorical values dropped by limit", attrs...)
|
||||
case st.Incomparable > 0:
|
||||
// Выше перезаписей намеренно: несравнимый набор полей — событие реже и
|
||||
// информативнее, на живом потоке не случавшееся ни разу. Признаки при
|
||||
@@ -479,12 +514,60 @@ func toIncoming(parsed hae.Result) store.Incoming {
|
||||
})
|
||||
}
|
||||
return store.Incoming{
|
||||
Points: points,
|
||||
Workouts: toEntities(parsed.Workouts),
|
||||
Records: toEntities(parsed.Records),
|
||||
Points: points,
|
||||
Workouts: toEntities(parsed.Workouts),
|
||||
Records: toEntities(parsed.Records),
|
||||
Categories: toCategories(parsed.Categoricals),
|
||||
}
|
||||
}
|
||||
|
||||
func toCategories(in []hae.Categorical) []store.CategoryValue {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]store.CategoryValue, 0, len(in))
|
||||
for _, c := range in {
|
||||
out = append(out, store.CategoryValue{
|
||||
Metric: c.Metric,
|
||||
Field: c.Field,
|
||||
Value: c.Value,
|
||||
Code: c.Code,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// localeOf достаёт язык доставки из сохранённых заголовков запроса.
|
||||
//
|
||||
// Отдельной колонки под язык нет намеренно: заголовки уже лежат целиком, и
|
||||
// вторая копия того же факта разошлась бы с первой при первой же правке приёма.
|
||||
//
|
||||
// Отсутствие заголовка, неразбираемый JSON и любая другая неожиданность дают
|
||||
// пустую локаль, а не отказ. Пустая локаль законна и вывода кода не отменяет:
|
||||
// заголовков в сыром архиве нет вовсе, поэтому доставка, восстановленная из
|
||||
// осиротевшего тела, приезжает без них — и обязана дать то же состояние.
|
||||
//
|
||||
// Имя заголовка ищется без учёта регистра: `http.Header` канонизирует его при
|
||||
// приёме, но в базе лежит то, что было записано, и правило регистра не наше.
|
||||
func localeOf(headers string) string {
|
||||
if headers == "" {
|
||||
return ""
|
||||
}
|
||||
var h map[string][]string
|
||||
if err := json.Unmarshal([]byte(headers), &h); err != nil {
|
||||
return ""
|
||||
}
|
||||
for name, values := range h {
|
||||
if !strings.EqualFold(name, acceptLanguageHeader) || len(values) == 0 {
|
||||
continue
|
||||
}
|
||||
return healthkit.Locale(values[0])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
const acceptLanguageHeader = "Accept-Language"
|
||||
|
||||
func toEntities(in []hae.Entity) []store.IncomingEntity {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
|
||||
Reference in New Issue
Block a user