Files
healthlog/internal/catalog/catalog_test.go
T
av 03edf1087d Каталог разрезов и измеренный род агрегации
- род метрики выводится сверкой минутного слоя с часовым: часовое значение
  сходится с суммой минутных — накопительная, со средним — мгновенная, иначе
  `unknown` и свёртка не предлагается вовсе. На живом архиве (123 доставки,
  31 метрика) 7 накопительных, 9 мгновенных, противоречащих часов ноль
- `GET /api/v1/metrics` под токеном чтения отдаёт единицы, слои с границами и
  род вместе с основанием измерения; род нигде не хранится — он функция витрины,
  а витрина функция журнала, устаревать в нём нечему
- миграция 00009: покрывающий индекс, чтобы каталог отвечал по учётным колонкам,
  не разжимая содержимое объектов
2026-08-02 19:23:59 +03:00

501 lines
18 KiB
Go

package catalog_test
import (
"context"
"fmt"
"log/slog"
"path/filepath"
"strings"
"testing"
"time"
"git.vakhrushev.me/av/healthlog/internal/catalog"
"git.vakhrushev.me/av/healthlog/internal/store"
)
func openStore(t *testing.T) *store.Store {
t.Helper()
st, err := store.Open(filepath.Join(t.TempDir(), "healthlog.db"))
if err != nil {
t.Fatalf("открытие базы: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
return st
}
func service(t *testing.T, st *store.Store) *catalog.Service {
t.Helper()
return catalog.New(st, slog.New(slog.DiscardHandler))
}
// logged собирает записи лога: чекпоинт, который никто не перехватывает, ничем
// не удерживается — его перевод на DEBUG или потеря атрибутов пройдут зелёным
// гейтом, а владелец о смене рода не узнает.
type logged struct {
records []slog.Record
}
func (h *logged) Enabled(context.Context, slog.Level) bool { return true }
func (h *logged) WithAttrs([]slog.Attr) slog.Handler { return h }
func (h *logged) WithGroup(string) slog.Handler { return h }
func (h *logged) Handle(_ context.Context, r slog.Record) error {
h.records = append(h.records, r.Clone())
return nil
}
func (h *logged) find(msg string) (slog.Record, bool) {
for _, r := range h.records {
if r.Message == msg {
return r, true
}
}
return slog.Record{}, false
}
func attr(r slog.Record, key string) string {
out := ""
r.Attrs(func(a slog.Attr) bool {
if a.Key == key {
out = a.Value.String()
return false
}
return true
})
return out
}
func incoming(metric, layer, units string, at time.Time, v float64) store.IncomingPoint {
return store.IncomingPoint{
Metric: metric,
Layer: layer,
Units: units,
Point: store.Point{Start: at, End: at, Raw: qty(v)},
}
}
func merge(t *testing.T, st *store.Store, points []store.IncomingPoint) {
t.Helper()
_, err := st.Merge(context.Background(), store.Incoming{Points: points},
store.DeliveryRef{ID: "delivery"})
if err != nil {
t.Fatalf("слияние: %v", err)
}
}
// метрику кладём в оба слоя за n часов: часовая точка на границе часа, три
// минутных внутри. `sum` задаёт, будет ли часовое значение суммой или средним.
func fill(t *testing.T, st *store.Store, metric string, hours int, sum bool) {
t.Helper()
var points []store.IncomingPoint
for i := range hours {
h := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC).Add(time.Duration(i) * time.Hour)
coarse := 2.0
if sum {
coarse = 6.0
}
points = append(points, incoming(metric, "hour", "count", h, coarse))
for m, v := range []float64{1, 2, 3} {
points = append(points, incoming(metric, "minute", "count", h.Add(time.Duration(m)*time.Minute), v))
}
}
merge(t, st, points)
}
func find(t *testing.T, metrics []catalog.Metric, name string) catalog.Metric {
t.Helper()
for _, m := range metrics {
if m.Metric == name {
return m
}
}
t.Fatalf("метрики %q в каталоге нет", name)
return catalog.Metric{}
}
func TestКаталогОтдаётРазрезыИИзмеренныйРод(t *testing.T) {
t.Parallel()
st := openStore(t)
fill(t, st, "step_count", 5, true)
fill(t, st, "heart_rate", 5, false)
// Метрика, лежащая только в нижнем слое: слой в каталоге есть, рода нет.
merge(t, st, []store.IncomingPoint{
incoming("sleep_analysis", "raw", "hr", time.Date(2026, 6, 1, 3, 7, 0, 0, time.UTC), 1),
})
metrics, err := service(t, st).Metrics(context.Background())
if err != nil {
t.Fatalf("каталог: %v", err)
}
steps := find(t, metrics, "step_count")
if steps.Aggregation.Style != catalog.Cumulative {
t.Errorf("step_count: род %v", steps.Aggregation.Style)
}
if len(steps.Units) != 1 || steps.Units[0] != "count" {
t.Errorf("step_count: единицы %v", steps.Units)
}
if len(steps.Layers) != 2 {
t.Errorf("step_count: слоёв %d, ждали 2", len(steps.Layers))
}
hr := find(t, metrics, "heart_rate")
if hr.Aggregation.Style != catalog.Instant {
t.Errorf("heart_rate: род %v", hr.Aggregation.Style)
}
sleep := find(t, metrics, "sleep_analysis")
if sleep.Aggregation.Style != catalog.Unknown {
t.Errorf("sleep_analysis: род %v, ждали unknown", sleep.Aggregation.Style)
}
if sleep.Aggregation.Hours != 0 || sleep.Aggregation.FirstHour != nil {
t.Errorf("sleep_analysis: основание %+v — сравнивать было нечего", sleep.Aggregation.Basis)
}
if len(sleep.Layers) != 1 || sleep.Layers[0].Layer != "raw" {
t.Errorf("sleep_analysis: слои %+v", sleep.Layers)
}
}
// Нижний слой в сверке не участвует: у метрики есть `raw` и `hour`, но нет
// `minute` — рода быть не должно, сколько бы данных ни лежало в нижнем слое.
func TestКаталогНеИзмеряетПоНижнемуСлою(t *testing.T) {
t.Parallel()
st := openStore(t)
var points []store.IncomingPoint
for i := range 5 {
h := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC).Add(time.Duration(i) * time.Hour)
points = append(points, incoming("active_energy", "hour", "kJ", h, 6))
for m, v := range []float64{1, 2, 3} {
points = append(points, incoming("active_energy", "raw",
"kJ", h.Add(time.Duration(m)*time.Second), v))
}
}
merge(t, st, points)
metrics, err := service(t, st).Metrics(context.Background())
if err != nil {
t.Fatalf("каталог: %v", err)
}
m := find(t, metrics, "active_energy")
if m.Aggregation.Style != catalog.Unknown {
t.Errorf("род %v, ждали unknown", m.Aggregation.Style)
}
if m.Aggregation.Hours != 0 {
t.Errorf("часов окна %d — нижний слой не имеет права попадать в сверку", m.Aggregation.Hours)
}
}
func TestКаталогОграничиваетОкно(t *testing.T) {
t.Parallel()
st := openStore(t)
fill(t, st, "step_count", catalog.Window+7, true)
metrics, err := service(t, st).Metrics(context.Background())
if err != nil {
t.Fatalf("каталог: %v", err)
}
m := find(t, metrics, "step_count")
if m.Aggregation.Hours != catalog.Window {
t.Errorf("часов окна %d, ждали %d", m.Aggregation.Hours, catalog.Window)
}
// Окно берёт САМЫЕ СВЕЖИЕ часы: старейший из сравненных обязан быть позже
// первого часа истории.
first := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
if m.Aggregation.FirstHour == nil || !m.Aggregation.FirstHour.After(first) {
t.Errorf("начало окна %v — окно взяло не свежие часы", m.Aggregation.FirstHour)
}
}
// Единицы, разошедшиеся между объектами, показываются множеством, а слой
// остаётся одним элементом: клиент, читающий слои словарём, иначе потерял бы
// половину диапазона молча.
func TestКаталогПоказываетРасхождениеЕдиниц(t *testing.T) {
t.Parallel()
st := openStore(t)
base := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
merge(t, st, []store.IncomingPoint{
incoming("walking_running_distance", "minute", "km", base, 1),
incoming("walking_running_distance", "minute", "m", base.Add(2*time.Hour), 2),
})
metrics, err := service(t, st).Metrics(context.Background())
if err != nil {
t.Fatalf("каталог: %v", err)
}
m := find(t, metrics, "walking_running_distance")
if len(m.Units) != 2 {
t.Errorf("единицы %v, ждали оба значения", m.Units)
}
if len(m.Layers) != 1 {
t.Fatalf("слоёв %d, ждали один элемент на слой: %+v", len(m.Layers), m.Layers)
}
if m.Layers[0].Points != 2 {
t.Errorf("точек в слое %d, ждали 2 — диапазоны обязаны объединиться", m.Layers[0].Points)
}
if !m.Layers[0].From.Equal(base) || !m.Layers[0].To.Equal(base.Add(2*time.Hour)) {
t.Errorf("границы слоя %v..%v", m.Layers[0].From, m.Layers[0].To)
}
}
func TestКаталогПустойВитриныПуст(t *testing.T) {
t.Parallel()
metrics, err := service(t, openStore(t)).Metrics(context.Background())
if err != nil {
t.Fatalf("каталог: %v", err)
}
if len(metrics) != 0 {
t.Errorf("метрик %d, ждали ноль", len(metrics))
}
}
// Род нигде не хранится: новая доставка меняет его в следующем же ответе, без
// перезапуска и без пересчёта чего бы то ни было.
func TestКаталогПересчитываетРодНаКаждыйЗапрос(t *testing.T) {
t.Parallel()
st := openStore(t)
svc := service(t, st)
ctx := context.Background()
fill(t, st, "step_count", 2, true)
before, err := svc.Metrics(ctx)
if err != nil {
t.Fatalf("каталог: %v", err)
}
if find(t, before, "step_count").Aggregation.Style != catalog.Unknown {
t.Fatal("двух часов не хватает на вердикт — ждали unknown")
}
fill(t, st, "step_count", 5, true)
after, err := svc.Metrics(ctx)
if err != nil {
t.Fatalf("каталог: %v", err)
}
if got := find(t, after, "step_count").Aggregation.Style; got != catalog.Cumulative {
t.Errorf("после доставки род %v, ждали cumulative", got)
}
}
// Час объекта берётся из метки в теле доставки, а тело не наше. Без верхней
// границы окна одна доставка с метками в будущем вытесняет всю настоящую
// историю метрики и подменяет измеренный род: мгновенная объявлялась
// накопительной при нуле противоречащих часов.
func TestКаталогНеИзмеряетПоБудущимЧасам(t *testing.T) {
t.Parallel()
st := openStore(t)
fill(t, st, "blood_oxygen_saturation", 6, false) // прошлое: среднее → instant
// Будущее: столько же часов «суммой», сколько влезает в окно целиком.
var future []store.IncomingPoint
base := store.Now().Add(72 * time.Hour).Truncate(time.Hour)
for i := range catalog.Window {
h := base.Add(time.Duration(i) * time.Hour)
future = append(future, incoming("blood_oxygen_saturation", "hour", "count", h, 6))
for m, v := range []float64{1, 2, 3} {
future = append(future, incoming("blood_oxygen_saturation", "minute", "count",
h.Add(time.Duration(m)*time.Minute), v))
}
}
merge(t, st, future)
handler := &logged{}
metrics, err := catalog.New(st, slog.New(handler)).Metrics(context.Background())
if err != nil {
t.Fatalf("каталог: %v", err)
}
m := find(t, metrics, "blood_oxygen_saturation")
if m.Aggregation.Style != catalog.Instant {
t.Errorf("род %v: часы из будущего заняли окно и подменили измерение", m.Aggregation.Style)
}
if m.Aggregation.LastHour == nil || m.Aggregation.LastHour.After(store.Now().Add(time.Hour)) {
t.Errorf("конец окна %v — окно ушло в будущее", m.Aggregation.LastHour)
}
if _, ok := handler.find("future data"); !ok {
t.Error("данные из будущего есть, а предупреждения владельцу нет")
}
}
// Единицы обеих сторон обязаны совпасть: минутный слой в count/min и часовой в
// count/hour дают «часовое = 60 · среднее = сумма» в полном часе, то есть
// уверенный ложный cumulative при нуле противоречащих часов.
func TestКаталогНеСверяетСлоиРазныхЕдиниц(t *testing.T) {
t.Parallel()
st := openStore(t)
var points []store.IncomingPoint
for i := range 6 {
h := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC).Add(time.Duration(i) * time.Hour)
points = append(points, incoming("walking_speed", "hour", "count/hour", h, 6))
for m, v := range []float64{1, 2, 3} {
points = append(points, incoming("walking_speed", "minute", "count/min",
h.Add(time.Duration(m)*time.Minute), v))
}
}
merge(t, st, points)
metrics, err := service(t, st).Metrics(context.Background())
if err != nil {
t.Fatalf("каталог: %v", err)
}
m := find(t, metrics, "walking_speed")
if m.Aggregation.Style != catalog.Unknown {
t.Errorf("род %v: единицы слоёв разошлись, сравнивать было нечего", m.Aggregation.Style)
}
if m.Aggregation.Compared != 0 {
t.Errorf("пригодных часов %d, ждали 0", m.Aggregation.Compared)
}
}
// Пустое имя метрики — законное значение колонки. Каталог отвечает на вопрос
// «что у тебя вообще есть», и терять на нём то, что лежит в витрине, нельзя.
func TestКаталогПоказываетМетрикуСПустымИменем(t *testing.T) {
t.Parallel()
st := openStore(t)
base := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
merge(t, st, []store.IncomingPoint{
incoming("", "minute", "count", base, 1),
incoming("step_count", "minute", "count", base, 2),
})
metrics, err := service(t, st).Metrics(context.Background())
if err != nil {
t.Fatalf("каталог: %v", err)
}
if len(metrics) != 2 {
t.Fatalf("метрик в каталоге %d, ждали 2: %+v", len(metrics), metrics)
}
if metrics[0].Metric != "" || metrics[0].Layers[0].Points != 1 {
t.Errorf("метрика с пустым именем потерялась: %+v", metrics[0])
}
}
func TestКаталогПишетПредупреждениеОПротиворечии(t *testing.T) {
t.Parallel()
st := openStore(t)
long := strings.Repeat("метрика", 200)
var points []store.IncomingPoint
base := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
for i := range 6 {
h := base.Add(time.Duration(i) * time.Hour)
coarse := 6.0 // сумма
if i >= 4 {
coarse = 2.0 // среднее — противоречие
}
points = append(points, incoming(long, "hour", "count", h, coarse))
for m, v := range []float64{1, 2, 3} {
points = append(points, incoming(long, "minute", "count", h.Add(time.Duration(m)*time.Minute), v))
}
}
merge(t, st, points)
handler := &logged{}
metrics, err := catalog.New(st, slog.New(handler)).Metrics(context.Background())
if err != nil {
t.Fatalf("каталог: %v", err)
}
if find(t, metrics, long).Aggregation.Style != catalog.Unknown {
t.Error("противоречие обязано гасить род")
}
rec, ok := handler.find("aggregation style conflict")
if !ok {
t.Fatal("противоречие есть, а предупреждения владельцу нет")
}
if rec.Level != slog.LevelWarn {
t.Errorf("уровень %v, ждали WARN: адресат записи — владелец", rec.Level)
}
if got := attr(rec, "metric"); len(got) > 128 {
t.Errorf("имя метрики в логе не обрезано: %d байт", len(got))
}
if attr(rec, "conflicting") == "" {
t.Error("в записи нет чисел основания — по ней нечего разбирать")
}
}
// Число различных единиц у метрики равно числу её объектов: без потолка одна
// запись каталога растёт вместе с витриной.
func TestКаталогОграничиваетЧислоЕдиниц(t *testing.T) {
t.Parallel()
st := openStore(t)
base := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
var points []store.IncomingPoint
for i := range 40 {
points = append(points, incoming("step_count", "minute",
fmt.Sprintf("unit-%02d", i), base.Add(time.Duration(i)*time.Hour), 1))
}
merge(t, st, points)
metrics, err := service(t, st).Metrics(context.Background())
if err != nil {
t.Fatalf("каталог: %v", err)
}
m := find(t, metrics, "step_count")
if len(m.Units) == 0 || len(m.Units) > 16 {
t.Errorf("единиц в ответе %d — потолок не работает", len(m.Units))
}
if len(m.Layers) != 1 {
t.Errorf("слоёв %d, ждали один элемент на слой", len(m.Layers))
}
}
// Отказ хранилища логируется доменной границей один раз и уходит наверх: у
// транспорта своей записи нет, он только переводит ошибку в ответ.
func TestКаталогСообщаетОбОтказеХранилища(t *testing.T) {
t.Parallel()
st := openStore(t)
if err := st.Close(); err != nil {
t.Fatalf("закрытие базы: %v", err)
}
handler := &logged{}
if _, err := catalog.New(st, slog.New(handler)).Metrics(context.Background()); err == nil {
t.Fatal("каталог на закрытой базе собрался")
}
rec, ok := handler.find("catalog failed")
if !ok {
t.Fatal("отказ не записан на доменной границе")
}
if rec.Level != slog.LevelError {
t.Errorf("уровень %v, ждали ERROR: сбой хранилища адресован владельцу", rec.Level)
}
}
// Отмена снаружи — «не сделано», а не «не выходит»: она не имеет права давать
// владельцу ERROR, иначе единственный канал настоящих сбоев забивается
// клиентами, оборвавшими запрос по своему тайм-ауту.
func TestКаталогНеПутаетОтменуСоСбоем(t *testing.T) {
t.Parallel()
st := openStore(t)
fill(t, st, "step_count", 3, true)
ctx, cancel := context.WithCancel(context.Background())
cancel()
handler := &logged{}
if _, err := catalog.New(st, slog.New(handler)).Metrics(ctx); err == nil {
t.Fatal("каталог собрался на отменённом контексте")
}
if _, ok := handler.find("catalog failed"); ok {
t.Error("отмена снаружи записана как сбой сервиса")
}
if _, ok := handler.find("catalog interrupted"); !ok {
t.Error("отмена не отмечена вовсе — исход операции обязан быть виден")
}
}