package fold_test import ( "context" "log/slog" "os" "path/filepath" "regexp" "strings" "testing" "time" "git.vakhrushev.me/av/healthlog/internal/archive" "git.vakhrushev.me/av/healthlog/internal/fold" "git.vakhrushev.me/av/healthlog/internal/store" ) func newFold(t *testing.T) (*fold.Service, *archive.Archive, *store.Store) { t.Helper() dir := t.TempDir() arch, err := archive.New(filepath.Join(dir, "raw")) if err != nil { t.Fatalf("архив: %v", err) } st, err := store.Open(filepath.Join(dir, "healthlog.db")) if err != nil { t.Fatalf("база: %v", err) } t.Cleanup(func() { _ = st.Close() }) log := slog.New(slog.DiscardHandler) return fold.New(arch, st, 0, log), arch, st } // deliver кладёт тело в архив и заводит доставку — ровно то, что делает приём. func deliver(t *testing.T, arch *archive.Archive, st *store.Store, id, aggregation, automationID string, body []byte) { t.Helper() at := store.Now() rawPath, err := arch.Write(id, at, body) if err != nil { t.Fatalf("запись в архив: %v", err) } err = st.CreateDelivery(context.Background(), store.Delivery{ ID: id, ReceivedAt: at, AutomationID: automationID, Aggregation: aggregation, Bytes: int64(len(body)), SHA256: "-", RawPath: rawPath, ParseStatus: store.ParsePending, }) if err != nil { t.Fatalf("запись доставки: %v", err) } } func fixture(t *testing.T, name string) []byte { t.Helper() body, err := os.ReadFile(filepath.Join("..", "hae", "testdata", name)) if err != nil { t.Fatalf("фикстура %s: %v", name, err) } return body } // Свёртка читает тело из архива по идентификатору доставки — тем же кодом, // каким его прочитает пересборка витрины. func TestFoldРаскладываетТочкиПоОбъектам(t *testing.T) { t.Parallel() f, arch, st := newFold(t) ctx := context.Background() deliver(t, arch, st, "d1", "Minutes", "auto-1", fixture(t, "minute.json")) stats, err := f.Fold(ctx, "d1") if err != nil { t.Fatalf("свёртка: %v", err) } if stats.Points == 0 || stats.Buckets == 0 { t.Fatalf("свёртка ничего не разложила: %+v", stats) } if stats.Layer != "minute" { t.Errorf("слой доставки %q, ожидался minute", stats.Layer) } n, err := st.CountBuckets(ctx) if err != nil { t.Fatalf("счёт объектов: %v", err) } if n != int64(stats.Buckets) { t.Errorf("объектов в базе %d, свёртка насчитала %d", n, stats.Buckets) } } // Слой сохраняется, чтобы его унаследовала следующая доставка той же // автоматизации: без плотных метрик выводить его не из чего, а после // перезапуска сервиса память об этом иначе теряется. func TestFoldСлойНаследуетсяВнутриАвтоматизации(t *testing.T) { t.Parallel() f, arch, st := newFold(t) ctx := context.Background() deliver(t, arch, st, "d1", "Minutes", "auto-1", fixture(t, "minute.json")) if _, err := f.Fold(ctx, "d1"); err != nil { t.Fatalf("первая свёртка: %v", err) } // Доставка без плотных метрик и с бесполезным заголовком: сама по себе // слой не выводится. deliver(t, arch, st, "d2", "Default", "auto-1", fixture(t, "sparse_sleep.json")) stats, err := f.Fold(ctx, "d2") if err != nil { t.Fatalf("вторая свёртка: %v", err) } if stats.Layer != "minute" { t.Errorf("слой %q, ожидался унаследованный minute", stats.Layer) } } // Наследовать нечего, заголовок ненадёжен: точки не сохраняются, доставка // помечается неразобранной и ждёт пересборки. Молчаливый выбор raw создал бы // призрачный разрез. func TestFoldНеопределимыйСлойНеПишетТочек(t *testing.T) { t.Parallel() f, arch, st := newFold(t) ctx := context.Background() deliver(t, arch, st, "d1", "Default", "auto-1", fixture(t, "sparse_sleep.json")) if _, err := f.Fold(ctx, "d1"); err == nil { t.Fatal("свёртка с неопределимым слоем прошла успешно") } n, err := st.CountBuckets(ctx) if err != nil { t.Fatalf("счёт объектов: %v", err) } if n != 0 { t.Errorf("записано %d объектов при неизвестном слое", n) } } // Повторная свёртка той же доставки не меняет состояния: журнал можно // проигрывать сколько угодно раз. func TestFoldИдемпотентен(t *testing.T) { t.Parallel() f, arch, st := newFold(t) ctx := context.Background() deliver(t, arch, st, "d1", "Minutes", "auto-1", fixture(t, "minute.json")) first, err := f.Fold(ctx, "d1") if err != nil { t.Fatalf("первая свёртка: %v", err) } second, err := f.Fold(ctx, "d1") if err != nil { t.Fatalf("повторная свёртка: %v", err) } if second.Unchanged != second.Buckets { t.Errorf("изменилось %d объектов из %d при повторе того же тела", second.Buckets-second.Unchanged, second.Buckets) } if first.Buckets != second.Buckets { t.Errorf("объектов %d против %d", first.Buckets, second.Buckets) } } // Порядок воспроизведения доставок не влияет на итог: свёртка по журналу // обязана давать то же состояние, что приём в реальном времени. func TestFoldНеЗависитОтПорядкаДоставок(t *testing.T) { t.Parallel() state := func(order []string) (int64, string) { f, arch, st := newFold(t) ctx := context.Background() bodies := map[string]string{"a": "minute.json", "b": "hour.json", "c": "raw.json"} aggs := map[string]string{"a": "Minutes", "b": "Hours", "c": "Default"} for _, id := range order { deliver(t, arch, st, id, aggs[id], "auto-"+id, fixture(t, bodies[id])) if _, err := f.Fold(ctx, id); err != nil { t.Fatalf("свёртка %s: %v", id, err) } } n, err := st.CountBuckets(ctx) if err != nil { t.Fatalf("счёт объектов: %v", err) } b, err := st.Bucket(ctx, "active_energy", "minute", mustHour(t, st, "active_energy", "minute")) if err != nil { return n, "" } return n, b.Hash } nForward, hForward := state([]string{"a", "b", "c"}) nBackward, hBackward := state([]string{"c", "b", "a"}) if nForward != nBackward { t.Errorf("объектов %d против %d при разном порядке доставок", nForward, nBackward) } if hForward != hBackward { t.Errorf("содержимое разошлось: %s против %s", hForward, hBackward) } } // mustHour находит час, в котором лежит объект метрики: фикстуры сдвинуты по // времени, и зашивать конкретную дату в тест значило бы ломать его при каждой // пересборке набора. func mustHour(t *testing.T, st *store.Store, metric, layer string) time.Time { t.Helper() hours, err := st.BucketHours(context.Background(), metric, layer) if err != nil { t.Fatalf("часы объекта: %v", err) } if len(hours) == 0 { t.Fatalf("объектов метрики %s слоя %s нет", metric, layer) } return hours[0] } // Доставка с непокрытой секцией обязана быть ОТЛИЧИМА от разобранной целиком. // Без этого ретеншен, ориентируясь на статус, срежет тело — а для stateOfMind // это необратимо: в экспорте Apple секции нет, доставки HAE единственный // источник. func TestFoldЧастичныйРазборВиденВУчёте(t *testing.T) { t.Parallel() f, arch, st := newFold(t) ctx := context.Background() deliver(t, arch, st, "d1", "Minutes", "a1", fixture(t, "uncovered_sections.json")) stats, err := f.Fold(ctx, "d1") if err != nil { t.Fatalf("свёртка: %v", err) } if len(stats.Uncovered) == 0 { t.Error("список непокрытых секций пуст") } d, err := st.LastDelivery(ctx) if err != nil { t.Fatalf("чтение доставки: %v", err) } if d.ParseStatus != store.ParsePartial { t.Errorf("статус %q, ожидался %q", d.ParseStatus, store.ParsePartial) } if !strings.Contains(d.UncoveredSections, "ecg") { t.Errorf("список в базе %q не содержит непокрытой секции", d.UncoveredSections) } // Точки метрик обязаны сохраниться: частичность не отменяет разобранного. if stats.Points == 0 { t.Error("точек нет — покрытая секция потерялась вместе с непокрытой") } } // Доставка из одних метрик списка не получает и остаётся parsed: частичность // производна от списка, а не назначена. func TestFoldПолныйРазборОстаётсяParsed(t *testing.T) { t.Parallel() f, arch, st := newFold(t) ctx := context.Background() deliver(t, arch, st, "d1", "Minutes", "a1", fixture(t, "minute.json")) if _, err := f.Fold(ctx, "d1"); err != nil { t.Fatalf("свёртка: %v", err) } d, err := st.LastDelivery(ctx) if err != nil { t.Fatalf("чтение доставки: %v", err) } if d.ParseStatus != store.ParseDone { t.Errorf("статус %q, ожидался %q", d.ParseStatus, store.ParseDone) } if d.UncoveredSections != "[]" { t.Errorf("список %q, ожидался `[]` — ровно одно представление пустоты", d.UncoveredSections) } } // Список замещает прежнее значение ЦЕЛИКОМ, включая замещение пустым. Иначе // доставка, чья секция стала покрытой, осталась бы partial навсегда, и // ретеншен вечно щадил бы тело, которое уже не нужно. func TestFoldПересвёрткаОчищаетСписок(t *testing.T) { t.Parallel() f, arch, st := newFold(t) ctx := context.Background() // Сперва доставка с непокрытой секцией. deliver(t, arch, st, "d1", "Minutes", "a1", fixture(t, "uncovered_sections.json")) if _, err := f.Fold(ctx, "d1"); err != nil { t.Fatalf("свёртка: %v", err) } // Затем — та же доставка, но тело уже без непокрытых секций: так выглядит // пересвёртка после того, как секцию научились разбирать. deliver(t, arch, st, "d2", "Minutes", "a1", fixture(t, "minute.json")) if _, err := f.Fold(ctx, "d2"); err != nil { t.Fatalf("свёртка: %v", err) } d, err := st.LastDelivery(ctx) if err != nil { t.Fatalf("чтение доставки: %v", err) } if d.UncoveredSections != "[]" { t.Errorf("список %q не очистился — пустой список обязан замещать прежний", d.UncoveredSections) } if d.ParseStatus != store.ParseDone { t.Errorf("статус %q, ожидался %q", d.ParseStatus, store.ParseDone) } } // Отмена снаружи не превращается в свойство доставки: работа не сделана, но // доставка остаётся в очереди и будет свёрнута снова. Иначе остановка сервиса в // неудачный момент выводила бы доставку из очереди навсегда — `failed` фоновая // свёртка не подбирает никогда, и вернуть её могла бы только пересборка с // остановкой сервиса и ручной подменой базы. func TestFoldОтменаОставляетДоставкуВОчереди(t *testing.T) { t.Parallel() f, arch, st := newFold(t) deliver(t, arch, st, "d1", "Minutes", "auto-1", fixture(t, "minute.json")) ctx, cancel := context.WithCancel(context.Background()) cancel() if _, err := f.Fold(ctx, "d1"); err == nil { t.Fatal("свёртка на отменённом контексте прошла успешно") } status, err := st.DeliveryStatus(context.Background(), "d1") if err != nil { t.Fatalf("DeliveryStatus: %v", err) } if status != store.ParsePending { t.Errorf("parse_status = %q, ожидался %q", status, store.ParsePending) } n, err := st.CountBuckets(context.Background()) if err != nil { t.Fatalf("CountBuckets: %v", err) } if n != 0 { t.Errorf("объектов %d: прерванная свёртка оставила половину", n) } } // Непонятое содержимое, наоборот, свойство самой доставки: повторять её // бесполезно, и она выводится из очереди. func TestFoldНепонятоеСодержимоеВыводитИзОчереди(t *testing.T) { t.Parallel() f, arch, st := newFold(t) ctx := context.Background() // Метрика есть, но слой определить нечем: плотных метрик нет, заголовок // ничего не означает, наследовать не от чего. body := []byte(`{"data":{"metrics":[{"name":"m","units":"u","data":[` + `{"date":"2026-07-31 12:00:00 +0300","qty":1}]}]}}`) deliver(t, arch, st, "d1", "Default", "auto-1", body) if _, err := f.Fold(ctx, "d1"); err == nil { t.Fatal("свёртка непонятого содержимого прошла успешно") } status, err := st.DeliveryStatus(ctx, "d1") if err != nil { t.Fatalf("DeliveryStatus: %v", err) } if status != store.ParseFailed { t.Errorf("parse_status = %q, ожидался %q", status, store.ParseFailed) } } // Пропущенная сущность обязана быть видна в БАЗЕ, а не только в логе. Ретеншен // сырого архива решает «что потеряется, если тело удалить», по учётной записи, // и до этого счётчика получал ответ «терять нечего» ровно там, где потеряна // тренировка с маршрутом: сущность в витрину не попала, список непокрытых // секций пуст, статус `parsed`. func TestFoldПропускиСущностейВидныВУчёте(t *testing.T) { t.Parallel() f, arch, st := newFold(t) ctx := context.Background() deliver(t, arch, st, "d1", "Minutes", "a1", fixture(t, "handmade_entities.json")) stats, err := f.Fold(ctx, "d1") if err != nil { t.Fatalf("свёртка: %v", err) } skipped := stats.SkippedNoID + stats.SkippedEntityNoTime + stats.SkippedEntityMalformed if skipped == 0 { t.Fatal("фикстура перестала давать пропуски — тест проверяет не то") } d, err := st.LastDelivery(ctx) if err != nil { t.Fatalf("чтение доставки: %v", err) } if d.SkippedEntities == nil { t.Fatal("счётчик пропусков пуст: доставка выглядит как «не измерялась»") } if *d.SkippedEntities != int64(skipped) { t.Errorf("в базе %d пропусков, разбор дал %d", *d.SkippedEntities, skipped) } } // Число замещает прежнее значение целиком, включая замещение нулём: доставка, // пропуски которой исчезли вместе с поумневшим разбором, не должна остаться // помеченной навсегда. func TestFoldПересвёрткаБезПропусковОбнуляетСчётчик(t *testing.T) { t.Parallel() f, arch, st := newFold(t) ctx := context.Background() deliver(t, arch, st, "d1", "Minutes", "a1", fixture(t, "handmade_entities.json")) if _, err := f.Fold(ctx, "d1"); err != nil { t.Fatalf("свёртка: %v", err) } before, err := st.LastDelivery(ctx) if err != nil { t.Fatalf("чтение доставки: %v", err) } if before.SkippedEntities == nil || *before.SkippedEntities == 0 { t.Fatal("фикстура перестала давать пропуски — тест проверяет не то") } // Тело подменяется на такое же, но без кривых элементов: ровно то, что // произойдёт при пересвёртке поумневшим разбором. deliver(t, arch, st, "d2", "Minutes", "a1", fixture(t, "workout_indoor.json")) if _, err := f.Fold(ctx, "d2"); err != nil { t.Fatalf("повторная свёртка: %v", err) } after, err := st.LastDelivery(ctx) if err != nil { t.Fatalf("чтение доставки: %v", err) } if after.SkippedEntities == nil || *after.SkippedEntities != 0 { t.Errorf("счётчик %v, ожидался ноль", after.SkippedEntities) } } // Доставка, свёрнутая разбором, который пропусков не считал, обязана быть // отличима от доставки с нулём: подстановка нуля объявила бы её проверенной, и // ретеншен получил бы ложное «терять нечего» с видом измерения. func TestFoldДоНачалаУчётаПропускиНеИзмерены(t *testing.T) { t.Parallel() _, arch, st := newFold(t) ctx := context.Background() deliver(t, arch, st, "d1", "Minutes", "a1", fixture(t, "minute.json")) d, err := st.LastDelivery(ctx) if err != nil { t.Fatalf("чтение доставки: %v", err) } if d.SkippedEntities != nil { t.Errorf("несвёрнутая доставка отдаёт %d вместо «не измерялось»", *d.SkippedEntities) } } // Отказ, при котором разбор не досчитал, обязан оставить счётчик НЕТРОНУТЫМ. // Ноль здесь означал бы «проверено, терять нечего» — то самое ложное измерение, // ради отказа от которого колонка заведена без умолчания. Тело при этом может // нести сотни тренировок, ни одна из которых не сохранена. func TestFoldОтказРазбораНеПодделываетСчётчикПропусков(t *testing.T) { t.Parallel() f, arch, st := newFold(t) ctx := context.Background() // Сперва успешная свёртка: счётчик измерен и ненулевой. deliver(t, arch, st, "d1", "Minutes", "a1", fixture(t, "handmade_entities.json")) if _, err := f.Fold(ctx, "d1"); err != nil { t.Fatalf("свёртка: %v", err) } before, err := st.LastDelivery(ctx) if err != nil { t.Fatalf("чтение доставки: %v", err) } if before.SkippedEntities == nil || *before.SkippedEntities == 0 { t.Fatal("фикстура перестала давать пропуски — тест проверяет не то") } // Теперь тело, которое разбор не понимает: секция есть, но конверт оборван. deliver(t, arch, st, "d2", "Minutes", "a1", []byte(`{"data":{"workouts":[`)) if _, err := f.Fold(ctx, "d2"); err == nil { t.Fatal("разбор оборванного тела не отказал") } after, err := st.LastDelivery(ctx) if err != nil { t.Fatalf("чтение доставки: %v", err) } if after.ID != "d2" { t.Fatalf("прочитана доставка %q, ожидалась d2", after.ID) } if after.SkippedEntities != nil { t.Errorf("отказ разбора записал %d пропусков как измерение", *after.SkippedEntities) } } // Удержание точки правилом полноты доезжает до итога свёртки и до лога: // счётчик отдельный от перезаписей, координаты — без значений. // // Тела строятся из РЕАЛЬНОГО пакета: у второй доставки из каждой точки убран // `qty`. Так получается ровно тот вход, на котором пришедшая точка теряет // содержание сохранённой, — единственный случай, когда под новым тай-брейком // она вообще проигрывает. func TestFoldУдержаниеТочкиСчитаетсяОтдельно(t *testing.T) { t.Parallel() f, arch, st := newFold(t) ctx := context.Background() rich := fixture(t, "minute.json") poor := regexp.MustCompile(`"qty"\s*:\s*[-0-9.eE+]+\s*,\s*`).ReplaceAll(rich, nil) if len(poor) == len(rich) { t.Fatal("фикстура не содержит qty — обеднить тело нечем") } deliver(t, arch, st, "d1", "Minutes", "auto-1", rich) if _, err := f.Fold(ctx, "d1"); err != nil { t.Fatalf("первая свёртка: %v", err) } deliver(t, arch, st, "d2", "Minutes", "auto-1", poor) stats, err := f.Fold(ctx, "d2") if err != nil { t.Fatalf("вторая свёртка: %v", err) } if stats.PointsHeld == 0 { t.Fatalf("удержаний 0: обеднённая точка вытеснила измерение, %+v", stats.MergeStats) } if len(stats.PointsHeldAt) == 0 { t.Error("координат удержания нет: счётчик без адреса неразбираем") } if stats.PointsHeld > stats.Overwrites { t.Errorf("удержаний %d при %d перезаписях: удержание — частный случай столкновения", stats.PointsHeld, stats.Overwrites) } }