Files
healthlog/internal/fold/categorical_test.go
av 1b649ba3d5 добавлен словарь категориальных значений HAE → коды HealthKit
- фазы сна, контекст пульса и имена тренировок попадают в реестр
  `category_value` (миграция 00010): строка хранится дословно, выведенный код
  лежит рядом отдельной записью, а не полем внутри точки
- словарь и синонимы кодов живут в бинаре (`internal/healthkit`); локаль из
  `Accept-Language` сужает поиск, но в ключ реестра не входит — заголовков в
  сыром архиве нет
- наблюдение входит в отпечаток витрины, выведенный код — нет: он производная
  от словаря, а не от журнала
2026-08-04 07:32:19 +03:00

278 lines
11 KiB
Go

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)
}
}