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) } }