Files
healthlog/internal/catalog/measure_test.go
T
av a834d10415 httpapi: форма провода читающих маршрутов объявлена транспортом
- доменные типы internal/catalog лишились json-тегов и MarshalJSON; типы
  metricWire/layerWire/aggregationWire и перевод catalogWire живут в транспорте,
  тело отказа тоже получило объявленный тип — байты ответа не изменились
- заведён сторож: обход графа типов ответа утверждает, что домен не доезжает до
  сериализации, плюс требование json-тега на полях транспортных структур и
  заведомо красные случаи к обоим правилам
- решение с ценой обеих сторон записано в architecture.md и ADR; шаг lint в
  гейте получил свой кеш — общий на машину красил прогон находками из чужого
  worktree
2026-08-04 16:18:05 +03:00

296 lines
10 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)
}
}
// Словарь рода живёт в домене, а его вид на проводе объявляет транспорт: род
// уезжает клиенту строкой, которую кладёт `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)
}
}
}