- доменные типы internal/catalog лишились json-тегов и MarshalJSON; типы metricWire/layerWire/aggregationWire и перевод catalogWire живут в транспорте, тело отказа тоже получило объявленный тип — байты ответа не изменились - заведён сторож: обход графа типов ответа утверждает, что домен не доезжает до сериализации, плюс требование json-тега на полях транспортных структур и заведомо красные случаи к обоим правилам - решение с ценой обеих сторон записано в architecture.md и ADR; шаг lint в гейте получил свой кеш — общий на машину красил прогон находками из чужого worktree
296 lines
10 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|