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("окно вне контракта принято без ошибки") } }) } }