- род метрики выводится сверкой минутного слоя с часовым: часовое значение сходится с суммой минутных — накопительная, со средним — мгновенная, иначе `unknown` и свёртка не предлагается вовсе. На живом архиве (123 доставки, 31 метрика) 7 накопительных, 9 мгновенных, противоречащих часов ноль - `GET /api/v1/metrics` под токеном чтения отдаёт единицы, слои с границами и род вместе с основанием измерения; род нигде не хранится — он функция витрины, а витрина функция журнала, устаревать в нём нечему - миграция 00009: покрывающий индекс, чтобы каталог отвечал по учётным колонкам, не разжимая содержимое объектов
180 lines
6.5 KiB
Go
180 lines
6.5 KiB
Go
package store_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.vakhrushev.me/av/healthlog/internal/store"
|
|
)
|
|
|
|
func window(hours int, horizon time.Time) store.CatalogWindow {
|
|
return store.CatalogWindow{
|
|
Fine: "minute", Coarse: "hour",
|
|
Hours: hours, Horizon: horizon,
|
|
CoarsePoints: 1, MinFinePoints: 2,
|
|
}
|
|
}
|
|
|
|
// hourly кладёт метрику в оба слоя за `hours` часов начиная с `base`.
|
|
func hourly(t *testing.T, st *store.Store, metric, units string, base time.Time, hours int) {
|
|
t.Helper()
|
|
|
|
var in []store.IncomingPoint
|
|
for i := range hours {
|
|
h := base.Add(time.Duration(i) * time.Hour)
|
|
in = append(in, store.IncomingPoint{
|
|
Metric: metric, Layer: "hour", Units: units,
|
|
Point: store.Point{Start: h, End: h, Raw: json.RawMessage(`{"qty":6}`)},
|
|
})
|
|
for m, v := range []string{`{"qty":1}`, `{"qty":2}`, `{"qty":3}`} {
|
|
at := h.Add(time.Duration(m) * time.Minute)
|
|
in = append(in, store.IncomingPoint{
|
|
Metric: metric, Layer: "minute", Units: units,
|
|
Point: store.Point{Start: at, End: at, Raw: json.RawMessage(v)},
|
|
})
|
|
}
|
|
}
|
|
if _, err := st.Merge(context.Background(), store.Incoming{Points: in},
|
|
store.DeliveryRef{ID: "delivery"}); err != nil {
|
|
t.Fatalf("слияние: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestReadCatalogОтдаётРазрезыИОкно(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
st := open(t)
|
|
base := ts(t, "2026-06-01T00:00:00Z")
|
|
hourly(t, st, "step_count", "count", base, 3)
|
|
|
|
snap, err := st.ReadCatalog(t.Context(), window(48, base.Add(24*time.Hour)))
|
|
if err != nil {
|
|
t.Fatalf("каталог: %v", err)
|
|
}
|
|
if len(snap.Layers) != 2 {
|
|
t.Fatalf("разрезов %d, ждали 2: %+v", len(snap.Layers), snap.Layers)
|
|
}
|
|
pairs := snap.Pairs["step_count"]
|
|
if len(pairs) != 3 {
|
|
t.Fatalf("общих часов %d, ждали 3", len(pairs))
|
|
}
|
|
// От свежих к старым — на этот порядок опирается и окно, и границы основания.
|
|
if !pairs[0].Hour.After(pairs[len(pairs)-1].Hour) {
|
|
t.Errorf("часы пришли не от свежих к старым: %v", pairs[0].Hour)
|
|
}
|
|
if len(pairs[0].Coarse) != 1 || len(pairs[0].Fine) != 3 {
|
|
t.Errorf("содержимое пары не прочитано: %+v", pairs[0])
|
|
}
|
|
if pairs[0].FineUnits != "count" || pairs[0].CoarseUnits != "count" {
|
|
t.Errorf("единицы сторон не прочитаны: %+v", pairs[0])
|
|
}
|
|
}
|
|
|
|
// Час объекта берётся из метки в теле доставки. Без горизонта одна доставка с
|
|
// метками в будущем занимает окно целиком и подменяет измеренный род.
|
|
func TestReadCatalogОтсекаетБудущиеЧасы(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
st := open(t)
|
|
base := ts(t, "2026-06-01T00:00:00Z")
|
|
hourly(t, st, "step_count", "count", base, 2)
|
|
hourly(t, st, "step_count", "count", ts(t, "2099-01-01T00:00:00Z"), 5)
|
|
|
|
horizon := base.Add(24 * time.Hour)
|
|
snap, err := st.ReadCatalog(t.Context(), window(48, horizon))
|
|
if err != nil {
|
|
t.Fatalf("каталог: %v", err)
|
|
}
|
|
pairs := snap.Pairs["step_count"]
|
|
if len(pairs) != 2 {
|
|
t.Fatalf("часов в окне %d, ждали 2: будущее не отсечено", len(pairs))
|
|
}
|
|
for _, p := range pairs {
|
|
if p.Hour.After(horizon) {
|
|
t.Errorf("час %v позже горизонта %v", p.Hour, horizon)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Окно ограничено сверху и берёт самые свежие часы.
|
|
func TestReadCatalogОграничиваетОкно(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
st := open(t)
|
|
base := ts(t, "2026-06-01T00:00:00Z")
|
|
hourly(t, st, "step_count", "count", base, 10)
|
|
|
|
snap, err := st.ReadCatalog(t.Context(), window(4, base.Add(48*time.Hour)))
|
|
if err != nil {
|
|
t.Fatalf("каталог: %v", err)
|
|
}
|
|
pairs := snap.Pairs["step_count"]
|
|
if len(pairs) != 4 {
|
|
t.Fatalf("часов %d, ждали 4", len(pairs))
|
|
}
|
|
if !pairs[0].Hour.Equal(base.Add(9 * time.Hour)) {
|
|
t.Errorf("самый свежий час %v, ждали %v", pairs[0].Hour, base.Add(9*time.Hour))
|
|
}
|
|
}
|
|
|
|
// Содержимое читается только у часов, прошедших отбор по учётным колонкам:
|
|
// разжимать заведомо непригодный час незачем, а знать о нём — обязательно.
|
|
func TestReadCatalogНеЧитаетСодержимоеНепригодныхЧасов(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
st := open(t)
|
|
base := ts(t, "2026-06-01T00:00:00Z")
|
|
in := []store.IncomingPoint{
|
|
// Часовой объект с двумя точками: час непригоден.
|
|
{Metric: "apple_stand_hour", Layer: "hour", Units: "count",
|
|
Point: store.Point{Start: base, End: base, Raw: json.RawMessage(`{"qty":1}`)}},
|
|
{Metric: "apple_stand_hour", Layer: "hour", Units: "count",
|
|
Point: store.Point{Start: base, End: base.Add(time.Hour), Raw: json.RawMessage(`{"qty":1}`)}},
|
|
}
|
|
for m, v := range []string{`{"qty":1}`, `{"qty":2}`} {
|
|
at := base.Add(time.Duration(m) * time.Minute)
|
|
in = append(in, store.IncomingPoint{Metric: "apple_stand_hour", Layer: "minute", Units: "count",
|
|
Point: store.Point{Start: at, End: at, Raw: json.RawMessage(v)}})
|
|
}
|
|
if _, err := st.Merge(t.Context(), store.Incoming{Points: in}, store.DeliveryRef{ID: "d"}); err != nil {
|
|
t.Fatalf("слияние: %v", err)
|
|
}
|
|
|
|
snap, err := st.ReadCatalog(t.Context(), window(48, base.Add(24*time.Hour)))
|
|
if err != nil {
|
|
t.Fatalf("каталог: %v", err)
|
|
}
|
|
pairs := snap.Pairs["apple_stand_hour"]
|
|
if len(pairs) != 1 {
|
|
t.Fatalf("часов %d, ждали 1", len(pairs))
|
|
}
|
|
if pairs[0].CoarsePoints != 2 || pairs[0].FinePoints != 2 {
|
|
t.Errorf("учётные колонки не прочитаны: %+v", pairs[0])
|
|
}
|
|
if pairs[0].Coarse != nil || pairs[0].Fine != nil {
|
|
t.Errorf("содержимое непригодного часа разжато напрасно: %+v", pairs[0])
|
|
}
|
|
}
|
|
|
|
func TestReadCatalogОтвергаетНеверноеОкно(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
st := open(t)
|
|
cases := map[string]store.CatalogWindow{
|
|
"нулевое окно": {Fine: "minute", Coarse: "hour", Hours: 0},
|
|
"слои совпадают": {Fine: "minute", Coarse: "minute", Hours: 48},
|
|
"окно отрицательно": {Fine: "minute", Coarse: "hour", Hours: -1},
|
|
}
|
|
for name, w := range cases {
|
|
t.Run(name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
if _, err := st.ReadCatalog(context.Background(), w); err == nil {
|
|
t.Error("окно вне контракта принято без ошибки")
|
|
}
|
|
})
|
|
}
|
|
}
|