- род метрики выводится сверкой минутного слоя с часовым: часовое значение сходится с суммой минутных — накопительная, со средним — мгновенная, иначе `unknown` и свёртка не предлагается вовсе. На живом архиве (123 доставки, 31 метрика) 7 накопительных, 9 мгновенных, противоречащих часов ноль - `GET /api/v1/metrics` под токеном чтения отдаёт единицы, слои с границами и род вместе с основанием измерения; род нигде не хранится — он функция витрины, а витрина функция журнала, устаревать в нём нечему - миграция 00009: покрывающий индекс, чтобы каталог отвечал по учётным колонкам, не разжимая содержимое объектов
293 lines
9.8 KiB
Go
293 lines
9.8 KiB
Go
package catalog_test
|
|
|
|
import (
|
|
"encoding/json"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.vakhrushev.me/av/healthlog/internal/catalog"
|
|
"git.vakhrushev.me/av/healthlog/internal/store"
|
|
)
|
|
|
|
func hour(n int) time.Time {
|
|
return time.Date(2026, 8, 1, n, 0, 0, 0, time.UTC).UTC()
|
|
}
|
|
|
|
// pair собирает час: одна часовая точка на границе часа и минутные точки внутри.
|
|
func pair(h time.Time, coarse float64, fine ...float64) store.HourPair {
|
|
p := store.HourPair{
|
|
Hour: h,
|
|
Coarse: []store.Point{{Start: h, End: h, Raw: qty(coarse)}},
|
|
}
|
|
for i, v := range fine {
|
|
at := h.Add(time.Duration(i) * time.Minute)
|
|
p.Fine = append(p.Fine, store.Point{Start: at, End: at, Raw: qty(v)})
|
|
}
|
|
return p
|
|
}
|
|
|
|
func qty(v float64) json.RawMessage {
|
|
b, err := json.Marshal(map[string]float64{"qty": v})
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return b
|
|
}
|
|
|
|
// window собирает окно из одинаковых по устройству часов, от свежих к старым —
|
|
// в том же порядке, в каком их отдаёт хранилище.
|
|
func window(n int, f func(h time.Time) store.HourPair) []store.HourPair {
|
|
out := make([]store.HourPair, 0, n)
|
|
for i := n - 1; i >= 0; i-- {
|
|
out = append(out, f(hour(i)))
|
|
}
|
|
return out
|
|
}
|
|
|
|
func TestMeasureНакопительная(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
pairs := window(4, func(h time.Time) store.HourPair {
|
|
return pair(h, 6, 1, 2, 3)
|
|
})
|
|
|
|
style, basis := catalog.Measure(pairs)
|
|
if style != catalog.Cumulative {
|
|
t.Fatalf("род: получили %v, ждали cumulative", style)
|
|
}
|
|
if basis.Agreeing != 4 || basis.Conflicting != 0 || basis.Compared != 4 || basis.Hours != 4 {
|
|
t.Errorf("основание: %+v", basis)
|
|
}
|
|
if basis.FirstHour == nil || !basis.FirstHour.Equal(hour(0)) {
|
|
t.Errorf("начало окна: %v", basis.FirstHour)
|
|
}
|
|
if basis.LastHour == nil || !basis.LastHour.Equal(hour(3)) {
|
|
t.Errorf("конец окна: %v", basis.LastHour)
|
|
}
|
|
}
|
|
|
|
func TestMeasureМгновенная(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
pairs := window(4, func(h time.Time) store.HourPair {
|
|
return pair(h, 2, 1, 2, 3)
|
|
})
|
|
|
|
style, basis := catalog.Measure(pairs)
|
|
if style != catalog.Instant {
|
|
t.Fatalf("род: получили %v, ждали instant", style)
|
|
}
|
|
if basis.Agreeing != 4 || basis.Conflicting != 0 {
|
|
t.Errorf("основание: %+v", basis)
|
|
}
|
|
}
|
|
|
|
// Нулевой час различить гипотезы не может: сумма равна среднему. Без этого
|
|
// фильтра `walking_asymmetry_percentage` на живом корпусе давала конфликт из
|
|
// одних нулевых часов.
|
|
func TestMeasureНулевойЧасНеСвидетельствует(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
pairs := window(5, func(h time.Time) store.HourPair {
|
|
return pair(h, 0, 0, 0, 0)
|
|
})
|
|
|
|
style, basis := catalog.Measure(pairs)
|
|
if style != catalog.Unknown {
|
|
t.Fatalf("род: получили %v, ждали unknown", style)
|
|
}
|
|
if basis.Compared != 0 || basis.Hours != 5 {
|
|
t.Errorf("основание: %+v — нулевые часы обязаны быть непригодны", basis)
|
|
}
|
|
}
|
|
|
|
func TestMeasureПротиворечиеГаситРод(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
pairs := []store.HourPair{
|
|
pair(hour(4), 6, 1, 2, 3), // сумма
|
|
pair(hour(3), 6, 1, 2, 3), // сумма
|
|
pair(hour(2), 6, 1, 2, 3), // сумма
|
|
pair(hour(1), 2, 1, 2, 3), // среднее
|
|
pair(hour(0), 12, 1, 2, 3), // ни то ни то
|
|
}
|
|
|
|
style, basis := catalog.Measure(pairs)
|
|
if style != catalog.Unknown {
|
|
t.Fatalf("род: получили %v, ждали unknown при противоречии", style)
|
|
}
|
|
if basis.Agreeing != 3 || basis.Conflicting != 1 {
|
|
t.Errorf("основание: %+v", basis)
|
|
}
|
|
if basis.Compared != 5 {
|
|
t.Errorf("пригодных часов: %d, ждали 5", basis.Compared)
|
|
}
|
|
}
|
|
|
|
func TestMeasureМалоСвидетельств(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
pairs := window(2, func(h time.Time) store.HourPair {
|
|
return pair(h, 6, 1, 2, 3)
|
|
})
|
|
|
|
style, basis := catalog.Measure(pairs)
|
|
if style != catalog.Unknown {
|
|
t.Fatalf("род: получили %v, ждали unknown при двух согласных часах", style)
|
|
}
|
|
if basis.Agreeing != 2 {
|
|
t.Errorf("согласных: %d", basis.Agreeing)
|
|
}
|
|
}
|
|
|
|
func TestMeasureОбщихЧасовНет(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
style, basis := catalog.Measure(nil)
|
|
if style != catalog.Unknown {
|
|
t.Fatalf("род: получили %v", style)
|
|
}
|
|
if basis.Hours != 0 || basis.FirstHour != nil || basis.LastHour != nil {
|
|
t.Errorf("основание: %+v — границ окна быть не должно", basis)
|
|
}
|
|
}
|
|
|
|
func TestMeasureНепригодныеЧасы(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
двеЧасовые := func(h time.Time) store.HourPair {
|
|
p := pair(h, 6, 1, 2, 3)
|
|
p.Coarse = append(p.Coarse, store.Point{Start: h, End: h.Add(time.Hour), Raw: qty(6)})
|
|
return p
|
|
}
|
|
однаМинутная := func(h time.Time) store.HourPair {
|
|
return pair(h, 1, 1)
|
|
}
|
|
// Метка часовой точки на середине часа UTC: так выглядит часовой слой в
|
|
// зоне с получасовым смещением, и сравнивать его с минутными точками того
|
|
// же объекта нельзя — они покрывают другой интервал.
|
|
сдвинутая := func(h time.Time) store.HourPair {
|
|
p := pair(h, 6, 1, 2, 3)
|
|
p.Coarse[0].Start = h.Add(30 * time.Minute)
|
|
return p
|
|
}
|
|
безЧисла := func(h time.Time) store.HourPair {
|
|
p := pair(h, 6, 1, 2, 3)
|
|
p.Coarse[0].Raw = json.RawMessage(`{"date":"…"}`)
|
|
return p
|
|
}
|
|
|
|
cases := map[string]func(time.Time) store.HourPair{
|
|
"часовой объект несёт две точки": двеЧасовые,
|
|
"минутный объект несёт одну": однаМинутная,
|
|
"часовая метка не выровнена": сдвинутая,
|
|
"часовая точка без числа": безЧисла,
|
|
}
|
|
|
|
for name, mk := range cases {
|
|
t.Run(name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
style, basis := catalog.Measure(window(5, mk))
|
|
if style != catalog.Unknown {
|
|
t.Errorf("род: получили %v, ждали unknown", style)
|
|
}
|
|
if basis.Compared != 0 {
|
|
t.Errorf("пригодных часов: %d, ждали 0 (основание %+v)", basis.Compared, basis)
|
|
}
|
|
if basis.Hours != 5 {
|
|
t.Errorf("часов окна: %d, ждали 5", basis.Hours)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Вердикт обязан быть функцией состава, а не порядка точек внутри объекта:
|
|
// порядок ключей и элементов в теле HAE нестабилен (находка 2).
|
|
func TestMeasureНеЗависитОтПорядкаТочек(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
forward := window(4, func(h time.Time) store.HourPair {
|
|
return pair(h, 6, 1, 2, 3)
|
|
})
|
|
backward := window(4, func(h time.Time) store.HourPair {
|
|
p := pair(h, 6, 1, 2, 3)
|
|
p.Fine[0], p.Fine[2] = p.Fine[2], p.Fine[0]
|
|
return p
|
|
})
|
|
|
|
s1, b1 := catalog.Measure(forward)
|
|
s2, b2 := catalog.Measure(backward)
|
|
// Границы окна сравниваются значением: в основании они указатели, чтобы
|
|
// «окна не было» отличалось от нулевой метки в ответе.
|
|
same := s1 == s2 &&
|
|
b1.Hours == b2.Hours && b1.Compared == b2.Compared &&
|
|
b1.Agreeing == b2.Agreeing && b1.Conflicting == b2.Conflicting &&
|
|
b1.FirstHour.Equal(*b2.FirstHour) && b1.LastHour.Equal(*b2.LastHour)
|
|
if !same {
|
|
t.Errorf("перестановка точек изменила исход: %v %+v против %v %+v", s1, b1, s2, b2)
|
|
}
|
|
}
|
|
|
|
// Точка без числа в сумму не входит и числа точек часа не увеличивает: час из
|
|
// одной значащей точки и одной пустой различить гипотезы не может.
|
|
func TestMeasureТочкаБезЧислаНеСчитается(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
pairs := window(5, func(h time.Time) store.HourPair {
|
|
p := pair(h, 1, 1)
|
|
p.Fine = append(p.Fine, store.Point{
|
|
Start: h.Add(time.Minute), End: h.Add(time.Minute),
|
|
Raw: json.RawMessage(`{"source":"часы"}`),
|
|
})
|
|
return p
|
|
})
|
|
|
|
_, basis := catalog.Measure(pairs)
|
|
if basis.Compared != 0 {
|
|
t.Errorf("пригодных часов: %d, ждали 0", basis.Compared)
|
|
}
|
|
}
|
|
|
|
// Перевес мгновенной гипотезы над накопительной: разложение основания одно на
|
|
// обе ветки, и `agreeing` всегда относится к преобладающему вердикту.
|
|
func TestMeasureПротиворечиеСПеревесомМгновенной(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
pairs := []store.HourPair{
|
|
pair(hour(4), 2, 1, 2, 3),
|
|
pair(hour(3), 2, 1, 2, 3),
|
|
pair(hour(2), 2, 1, 2, 3),
|
|
pair(hour(1), 2, 1, 2, 3),
|
|
pair(hour(0), 6, 1, 2, 3),
|
|
}
|
|
|
|
style, basis := catalog.Measure(pairs)
|
|
if style != catalog.Unknown {
|
|
t.Fatalf("род: получили %v, ждали unknown", style)
|
|
}
|
|
if basis.Agreeing != 4 || basis.Conflicting != 1 {
|
|
t.Errorf("основание: %+v — согласные обязаны быть у преобладающей гипотезы", basis)
|
|
}
|
|
}
|
|
|
|
func TestStyleСловарь(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cases := map[catalog.Style]string{
|
|
catalog.Cumulative: `"cumulative"`,
|
|
catalog.Instant: `"instant"`,
|
|
catalog.Unknown: `"unknown"`,
|
|
catalog.Style(42): `"unknown"`,
|
|
}
|
|
for style, want := range cases {
|
|
got, err := json.Marshal(style)
|
|
if err != nil {
|
|
t.Fatalf("сериализация %v: %v", style, err)
|
|
}
|
|
if string(got) != want {
|
|
t.Errorf("род %d: получили %s, ждали %s", style, got, want)
|
|
}
|
|
}
|
|
}
|