- `GET /api/v1/metrics/{name}?from&to&layer` — ряд точек за период; конверт
объявляет слой, измеренный род, его применимость к отданному ряду и границу
окна измерения, а сам ряд собирается из одного слоя, выбранного по охвату
точек внутри периода
- use-case вынесен в `internal/points`, чтение — одним входом `store.ReadSeries`
под одной транзакцией; правило выбора слоя остаётся в домене и приходит в
хранилище колбэком
- `writeJSON` перестал экранировать HTML-символы и перестал глушить отказ
записи: дословность содержимого точки иначе не удерживается, а оборванное
тело уходило под видом успешного `200`
133 lines
4.8 KiB
Go
133 lines
4.8 KiB
Go
package points
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
|
|
"git.vakhrushev.me/av/healthlog/internal/catalog"
|
|
"git.vakhrushev.me/av/healthlog/internal/store"
|
|
)
|
|
|
|
func day(d int) time.Time { return time.Date(2026, 6, d, 0, 0, 0, 0, time.UTC) }
|
|
|
|
func span(layer string, first, last time.Time) store.LayerSpan {
|
|
return store.LayerSpan{Layer: layer, First: first, Last: last}
|
|
}
|
|
|
|
// Правило выбора слоя целиком: охват, равенство охватов, выбывание по пустому
|
|
// пересечению и вырожденный вход.
|
|
func TestВыборСлояПоОхвату(t *testing.T) {
|
|
from, to := day(1), day(30)
|
|
|
|
cases := []struct {
|
|
name string
|
|
spans []store.LayerSpan
|
|
want string
|
|
}{
|
|
{
|
|
// «Вес за год»: нижний слой плотнее, но короче. Победа по числу
|
|
// точек вернула бы три дня вместо периода и не сказала бы ни слова.
|
|
name: "мелкий слой охватывает меньше крупного",
|
|
spans: []store.LayerSpan{
|
|
span("raw", day(1), day(4)),
|
|
span("hour", day(1), day(29)),
|
|
},
|
|
want: "hour",
|
|
},
|
|
{
|
|
name: "охваты равны — побеждает мелкий",
|
|
spans: []store.LayerSpan{
|
|
span("hour", day(1), day(29)),
|
|
span("minute", day(1), day(29)),
|
|
},
|
|
want: "minute",
|
|
},
|
|
{
|
|
// Слой, чьи данные лежат целиком вне периода, выбывает — иначе он
|
|
// выиграл бы охват и отдал пустой ряд.
|
|
name: "слой лежит вне периода",
|
|
spans: []store.LayerSpan{
|
|
span("hour", day(1).Add(-48*time.Hour), day(1).Add(-24*time.Hour)),
|
|
span("minute", day(2), day(3)),
|
|
},
|
|
want: "minute",
|
|
},
|
|
{
|
|
// Пересечение считается по ПЕРИОДУ, а не по данным: слой, торчащий
|
|
// за обе границы, не получает бесконечного охвата.
|
|
name: "слой шире периода",
|
|
spans: []store.LayerSpan{
|
|
span("hour", day(1).Add(-240*time.Hour), day(30).Add(240*time.Hour)),
|
|
span("minute", day(1), day(30)),
|
|
},
|
|
want: "minute",
|
|
},
|
|
{
|
|
name: "слоёв нет вовсе",
|
|
spans: nil,
|
|
want: "",
|
|
},
|
|
{
|
|
// Единственная точка ровно на границе периода: охват нулевой, но
|
|
// слой пригоден — точка в ответ попадёт.
|
|
name: "единственная точка на границе",
|
|
spans: []store.LayerSpan{span("raw", from, from)},
|
|
want: "raw",
|
|
},
|
|
{
|
|
// Точка ровно на `to` в период не входит (полуинтервал), значит и
|
|
// слой из выбора выбывает.
|
|
name: "единственная точка на правой границе",
|
|
spans: []store.LayerSpan{span("raw", to, to)},
|
|
want: "",
|
|
},
|
|
}
|
|
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
if got := pickLayer(from, to, c.spans); got != c.want {
|
|
t.Errorf("выбран слой %q, ожидался %q", got, c.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Порядок слоёв в выборке не должен влиять на исход: иначе ответ стал бы
|
|
// функцией порядка строк, который задаёт SQLite, а не правило.
|
|
func TestВыборСлояНеЗависитОтПорядка(t *testing.T) {
|
|
from, to := day(1), day(30)
|
|
a := span("hour", day(1), day(29))
|
|
b := span("minute", day(1), day(29))
|
|
|
|
if pickLayer(from, to, []store.LayerSpan{a, b}) != pickLayer(from, to, []store.LayerSpan{b, a}) {
|
|
t.Error("выбор слоя зависит от порядка охватов")
|
|
}
|
|
}
|
|
|
|
// Применимость рода к ОТДАННОМУ ряду. Разряд, ради которого поле заведено:
|
|
// накопительная метрика на нижнем слое HAE неприменима, потому что нижний слой
|
|
// это интерполяция, а не сэмплы, и сумма по нему завышает втрое.
|
|
func TestПрименимостьРодаКРяду(t *testing.T) {
|
|
cases := []struct {
|
|
style catalog.Style
|
|
layer string
|
|
want bool
|
|
}{
|
|
{catalog.Cumulative, "raw", false},
|
|
{catalog.Cumulative, "minute", true},
|
|
{catalog.Cumulative, "hour", true},
|
|
{catalog.Cumulative, "sample", true},
|
|
{catalog.Instant, "raw", true},
|
|
{catalog.Unknown, "minute", false},
|
|
{catalog.Unknown, "raw", false},
|
|
{catalog.Cumulative, "", false},
|
|
{catalog.Instant, "", false},
|
|
}
|
|
|
|
for _, c := range cases {
|
|
if got := applicable(c.style, c.layer); got != c.want {
|
|
t.Errorf("применимость %s на слое %q = %v, ожидалось %v", c.style, c.layer, got, c.want)
|
|
}
|
|
}
|
|
}
|