Триаж свёл 62 сырые находки девяти проходов к 33 причинам: 3 блокера, 4 «сейчас», 2 развилки. Все закрыты регрессионными тестами. - схема точки сна определяется по самой точке, а не по индексу в исходном массиве: одна пропущенная точка меняла эпизод и сводку местами - доставка сворачивается одной транзакцией: частичное состояние было недетерминированным (восемь прогонов — семь состояний) - граница размера на распакованном теле: 400 КиБ gzip разворачивались в 400 МиБ мимо max_body_mb - столкновение — расхождение канонических форм, а не байтов; WARN с координатами объекта; payload без HTML-экранирования - выравнивание по местной метке: получасовые зоны уводили часовую выгрузку в minute - доставка из одних суточных сводок больше не отвергается целиком - единицы не переписываются молча; счётчик считает сохранённые точки - все выходы Fold логируются, исход пишется на переживающем отмену контексте Четыре развилки вынесены блокерами в беклог.
148 lines
6.7 KiB
Go
148 lines
6.7 KiB
Go
package hae_test
|
||
|
||
import (
|
||
"errors"
|
||
"testing"
|
||
|
||
"git.vakhrushev.me/av/healthlog/internal/hae"
|
||
)
|
||
|
||
// Схема точки определяется по ней самой, а не по её месту в исходном массиве.
|
||
//
|
||
// Соответствие «разобранная точка ↔ исходный элемент» по индексу было неверным:
|
||
// список разобранных отфильтрован пропусками, и одна пропущенная точка сдвигала
|
||
// разметку всех последующих — эпизод сна уезжал под имя суточной сводки со
|
||
// слоем `day`, сводка оставалась под именем эпизода. Метрика и слой входят в
|
||
// координату точки, поэтому ошибка необратима: пересборка воспроизвела бы её,
|
||
// а точки из объекта не удаляются никогда.
|
||
func TestParseСхемаСнаНеЗависитОтПропущенныхТочек(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
const body = `{"data":{"metrics":[{"name":"sleep_analysis","units":"hr","data":[
|
||
null,
|
||
{"date":"","qty":1},
|
||
{"date":"2025-06-06 00:00:00 +0300","totalSleep":7.5,"core":4.0,"deep":1.0,"rem":2.5},
|
||
{"date":"2025-06-05 22:04:00 +0300","start":"2025-06-05 22:04:00 +0300","end":"2025-06-05 23:16:00 +0300","value":"Во сне","qty":1.2}
|
||
]}]}}`
|
||
|
||
res, err := hae.Parse([]byte(body), hae.Meta{Aggregation: "Minutes"})
|
||
if err != nil {
|
||
t.Fatalf("разбор: %v", err)
|
||
}
|
||
|
||
got := map[string]hae.Layer{}
|
||
for _, p := range res.Points {
|
||
got[p.Metric] = p.Layer
|
||
}
|
||
|
||
if got["sleep_analysis_summary"] != hae.LayerDay {
|
||
t.Errorf("сводка получила метрику/слой %v, ожидался sleep_analysis_summary/day", got)
|
||
}
|
||
if got["sleep_analysis"] == hae.LayerDay {
|
||
t.Errorf("эпизод получил слой day — разметка съехала: %v", got)
|
||
}
|
||
if res.SkippedNoTime+res.SkippedMalformed != 2 {
|
||
t.Errorf("пропущено %d+%d точек, ожидалось 2",
|
||
res.SkippedNoTime, res.SkippedMalformed)
|
||
}
|
||
}
|
||
|
||
// Доставка, состоящая из одних суточных сводок, слоя не требует — он у них
|
||
// назначен схемой. Отказ ради неё был бы необратим: исход детерминирован, и
|
||
// пересборка повторяла бы его вечно, то есть автоматизация, настроенная только
|
||
// на сон, не донесла бы данных никогда.
|
||
func TestParseДоставкаИзОднихСводокСохраняется(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
body := `{"data":{"metrics":[{"name":"sleep_analysis","units":"hr","data":[`
|
||
for i := range 12 {
|
||
if i > 0 {
|
||
body += ","
|
||
}
|
||
body += `{"date":"2025-06-0` + string(rune('1'+i%9)) + ` 00:00:00 +0300","totalSleep":7.5}`
|
||
}
|
||
body += `]}]}}`
|
||
|
||
res, err := hae.Parse([]byte(body), hae.Meta{Aggregation: "Default"})
|
||
if err != nil {
|
||
t.Fatalf("разбор: %v", err)
|
||
}
|
||
if len(res.Points) != 12 {
|
||
t.Fatalf("точек %d, ожидалось 12", len(res.Points))
|
||
}
|
||
for _, p := range res.Points {
|
||
if p.Metric != "sleep_analysis_summary" || p.Layer != hae.LayerDay {
|
||
t.Fatalf("точка %s/%s, ожидалось sleep_analysis_summary/day", p.Metric, p.Layer)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Слой считается по МЕСТНОЙ метке: HAE строит сетку в зоне телефона. По UTC
|
||
// ровный местный час в зоне со смещением на половину превращается в метку
|
||
// hh:30, и вся часовая выгрузка уехала бы в слой minute — где столкнулась бы с
|
||
// настоящей минутной автоматизацией и завысила сумму минутного слоя вдвое.
|
||
func TestParseСлойВЗонеСПолучасовымСмещением(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
for _, offset := range []string{"+0300", "+0530", "+0545", "+0930", "-0330"} {
|
||
t.Run(offset, func(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
body := `{"data":{"metrics":[{"name":"step_count","units":"count","data":[`
|
||
for h := range 12 {
|
||
if h > 0 {
|
||
body += ","
|
||
}
|
||
body += `{"date":"2025-06-05 ` + string(rune('0'+h/10)) + string(rune('0'+h%10)) +
|
||
`:00:00 ` + offset + `","qty":1}`
|
||
}
|
||
body += `]}]}}`
|
||
|
||
res, err := hae.Parse([]byte(body), hae.Meta{Aggregation: "Default"})
|
||
if err != nil {
|
||
t.Fatalf("разбор: %v", err)
|
||
}
|
||
if res.Layer != hae.LayerHour {
|
||
t.Errorf("слой %q при смещении %s, ожидался hour", res.Layer, offset)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// Интервал, конец которого не читается, вырождать в мгновенную точку нельзя:
|
||
// две записи с общим началом получили бы одну координату, и одна исчезла бы
|
||
// молча. Пропускаем со счётчиком — тело остаётся в архиве.
|
||
func TestParseНеразобранныйКонецПропускаетТочку(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
const body = `{"data":{"metrics":[{"name":"sleep_analysis","units":"hr","data":[
|
||
{"date":"2025-06-05 22:04:00 +0300","start":"2025-06-05 22:04:00 +0300","end":"сломано","value":"Во сне","qty":1},
|
||
{"date":"2025-06-05 22:04:00 +0300","start":"2025-06-05 22:04:00 +0300","end":"2025-06-06 02:21:00 +0300","value":"В кровати","qty":4}
|
||
]}]}}`
|
||
|
||
res, err := hae.Parse([]byte(body), hae.Meta{Aggregation: "Minutes"})
|
||
if err != nil {
|
||
t.Fatalf("разбор: %v", err)
|
||
}
|
||
if res.SkippedBadEnd != 1 {
|
||
t.Errorf("точек с нечитаемым концом %d, ожидалась 1", res.SkippedBadEnd)
|
||
}
|
||
if len(res.Points) != 1 {
|
||
t.Errorf("точек %d, ожидалась 1: вырожденная точка схлопнулась бы с соседней", len(res.Points))
|
||
}
|
||
}
|
||
|
||
// Проверка, что отказ по неопределимому слою остался ровно там, где он нужен:
|
||
// у доставки с обычными метриками и без всякой опоры.
|
||
func TestParseОтказПоСлоюОсталсяДляОбычныхМетрик(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
const body = `{"data":{"metrics":[{"name":"step_count","units":"count","data":[
|
||
{"date":"2025-06-05 10:00:00 +0300","qty":1}
|
||
]}]}}`
|
||
|
||
if _, err := hae.Parse([]byte(body), hae.Meta{Aggregation: "Default"}); !errors.Is(err, hae.ErrLayerUnknown) {
|
||
t.Fatalf("ошибка %v, ожидалась ErrLayerUnknown", err)
|
||
}
|
||
}
|