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) } } // Словарь рода живёт в домене, а его вид на проводе объявляет транспорт: род // уезжает клиенту строкой, которую кладёт `internal/httpapi`, вызывая этот же // `String()`. Поэтому проверяется словарь, а не сериализация — второй словарь на // проводе разошёлся бы с этим молча. // // Незнакомое значение даёт `unknown`, а не пустую строку: клиент не должен // видеть состояние, которого в словаре нет. 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 { if got := style.String(); got != want { t.Errorf("род %d: получили %q, ждали %q", style, got, want) } } }