- род метрики выводится сверкой минутного слоя с часовым: часовое значение сходится с суммой минутных — накопительная, со средним — мгновенная, иначе `unknown` и свёртка не предлагается вовсе. На живом архиве (123 доставки, 31 метрика) 7 накопительных, 9 мгновенных, противоречащих часов ноль - `GET /api/v1/metrics` под токеном чтения отдаёт единицы, слои с границами и род вместе с основанием измерения; род нигде не хранится — он функция витрины, а витрина функция журнала, устаревать в нём нечему - миграция 00009: покрывающий индекс, чтобы каталог отвечал по учётным колонкам, не разжимая содержимое объектов
501 lines
18 KiB
Go
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("отмена не отмечена вовсе — исход операции обязан быть виден")
|
|
}
|
|
}
|