- байтовый порядок канонических форм остался тай-брейком только внутри одной доставки: на живом корпусе он решал 98,8% спорных координат и системно хранил меньшее значение, из-за чего step_count терял род и verify:archive был красным - правило перестало быть коммутативным осознанно, поэтому порядок свёртки приведён к журнальному: проход воркера прекращается на отложенной доставке, а свёртка вне порядка журнала пишет WARN - заведены счётчики PointsHeld и PointsErased — удержание полнотой и единственное направление, в котором правило теряет содержание
560 lines
22 KiB
Go
560 lines
22 KiB
Go
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)
|
|
}
|
|
}
|