package replay_test import ( "bytes" "compress/gzip" "context" "flag" "io" "log/slog" "os" "path/filepath" "sort" "strings" "testing" "time" "git.vakhrushev.me/av/healthlog/internal/catalog" "git.vakhrushev.me/av/healthlog/internal/hae" "git.vakhrushev.me/av/healthlog/internal/ident" "git.vakhrushev.me/av/healthlog/internal/store" ) // archiveDir включает прогон сходимости на живом архиве. // // Флагом, а не переменной окружения и не путём по умолчанию: архив в // репозиторий не попадает (данные о здоровье), прогон занимает минуту и не // должен висеть на каждом `task gate`. Запускается командой // `task verify:archive`. var archiveDir = flag.String("healthlog.archive", "", "каталог сырого архива для прогона сходимости (по умолчанию прогон пропускается)") // Сходимость на живом архиве: тот же корпус, на котором выводились правила // разбора, обязан пройти через код без потерь и без расхождений — и повторное // проигрывание журнала обязано дать то же состояние. // // Прогон идёт через ту же операцию, которой пересобирает витрину // `healthlog reindex`. Собственный обход и собственный порядок здесь были // раньше и были ошибкой: они образовывали второй проигрыватель журнала, чьи // правила разошлись с настоящим, — а зеленел бы при этом он. func TestReplayЖивогоАрхива(t *testing.T) { if *archiveDir == "" { t.Skip("прогон живого архива выключен: задайте -healthlog.archive") } bodies := collectBodies(t, *archiveDir) if len(bodies) == 0 { t.Skipf("живого архива нет в %s — прогон пропущен", *archiveDir) } dir := t.TempDir() arch := openArchive(t, filepath.Join(dir, "raw")) src := openStore(t, filepath.Join(dir, "live.db")) ctx := context.Background() // Тела в архиве сжаты; распаковываем и кладём через тот же архив, чтобы // путь чтения был ровно тот, каким пойдёт пересборка. // // Заголовки доставки в архиве не лежат — они были заголовками запроса. // Поэтому автоматизация у всех одна: так проверяется в том числе // наследование слоя по цепочке доставок. Метка приёма берётся из ULID, // то есть хронология журнала настоящая. for _, path := range bodies { body, err := os.ReadFile(path) if err != nil { t.Fatalf("чтение %s: %v", path, err) } id := strings.TrimSuffix(filepath.Base(path), ".json.gz") at, err := ident.TimeOf(id) if err != nil { t.Fatalf("время из ULID %s: %v", id, err) } writeBody(t, arch, src, item{id: id, at: at, automationID: "auto"}, gunzip(t, body)) } first, dst := run(t, ctx, arch, src, filepath.Join(dir, "first.db")) // Несравнимые наборы полей — посылка, на которой стоит отказ от объединения // полей: их не было ни разу на всём корпусе. Число печатается, а не // проверяется: появление такого набора — событие для разбора, а не отказ // сходимости. t.Logf("тел %d: свёрнуто %d; отказов: слой %d, содержимое %d, прочее %d; несравнимых наборов %d", first.Bodies, first.Folded, first.FailedLayer, first.FailedMalformed, first.FailedOther, first.Incomparable) t.Logf("частично разобрано %d, подобрано без учёта %d, записей без тела %d", first.Partial, first.Adopted, first.Orphans) if first.Folded == 0 { t.Fatal("ни одна доставка не свернулась") } // Половина живого потока не несёт metrics вовсе (находка 50): это // тренировки и состояние разума. С тех пор как обе секции покрыты, // частично разобранных доставок в архиве нет — и вместо них проверяется то, // ради чего покрытие делалось: сущности доехали до витрины. // // Проверяется свойство, а не число: корпус растёт с каждой доставкой, а // прогон живого архива в гейт не входит, так что протухшая константа // покраснела бы молча. if first.Workouts == 0 { t.Error("тренировок в витрине нет — секция workouts не разбирается") } if first.Records == 0 { t.Error("записей в витрине нет — секция stateOfMind не разбирается") } // Удержанные версии сущностей печатаются рядом с их числом. Число — это // «сколько лежит», а удержания — «сколько правило слияния не пустило», и // второе отпечатком не проверяется по построению: живой приём и пересборка // пользуются одним правилом и одинаково сойдутся на одинаково удержанной // версии. Печатается, а не утверждается: удержание — событие для разбора, // а не отказ сходимости. t.Logf("тренировок %d, записей %d, удержано версий сущностей %d", first.Workouts, first.Records, first.EntitiesHeld) // Повторное проигрывание того же журнала даёт то же состояние: свёртка // детерминирована, и пересборка даёт то же, что живой приём. // // Сравнивается ОТПЕЧАТОК содержимого, а не число объектов: на координате // всегда лежит ровно одна точка, и правило разрешения столкновений выбирает, // какая это будет точка, а не сколько их. Счёт объектов совпал бы и при // заведомо сломанном правиле. second, _ := run(t, ctx, arch, src, filepath.Join(dir, "second.db")) if second.Buckets != first.Buckets { t.Errorf("повторное проигрывание изменило число объектов: %d → %d", first.Buckets, second.Buckets) } if second.Workouts != first.Workouts || second.Records != first.Records { t.Errorf("повторное проигрывание изменило число сущностей: %d/%d → %d/%d", first.Workouts, first.Records, second.Workouts, second.Records) } if second.Fingerprint != first.Fingerprint { t.Errorf("повторное проигрывание изменило содержимое объектов:\n %s\n %s", first.Fingerprint, second.Fingerprint) } // Отпечаток печатается всегда: это единственный способ сравнить состояние с // тем, что давала прежняя редакция правила слияния. Эталон в репозитории не // живёт — он производен от архива, которого нет ни на одной другой машине. // Значений точек отпечаток не раскрывает: содержимое входит в него хешем. t.Logf("объектов %d, отпечаток содержимого %s", first.Buckets, first.Fingerprint) measureStyles(t, dst) // Главное свойство ключа: у записей сна он ИНТЕРВАЛ, а не метка — под одним // `date` лежит до трёх записей (docs/research/apple-health.md, находка 47). // // Проверяется само свойство, а не измеренное когда-то число. Прежняя // редакция сравнивала с константой 174, снятой на 94 доставках, и покраснела // молча, когда архив дорос до 116: константа, производная от корпуса, // протухает с каждой новой доставкой, а прогон живого архива в гейт не // входит, так что краснота никому не видна. coords, labels := countSleepKeys(t, dst) if coords == 0 { t.Fatal("записей сна в витрине нет — проверять нечего") } if coords <= labels { t.Errorf("координат сна %d при %d различных метках: ключ схлопнул записи до метки", coords, labels) } t.Logf("координат сна %d, различных меток %d", coords, labels) } // measureStyles прогоняет измерение рода агрегации на витрине, собранной из // живого архива, — единственное место, где правило проверяется на настоящем // потоке, а не на фикстурах. // // Утверждаются СВОЙСТВА, а не числа: корпус растёт с каждой доставкой, а прогон // живого архива в гейт не входит, так что константа, производная от размера // корпуса, покраснела бы молча (docs/review.md, 2026-08-02). Измеренные // числа печатаются. func measureStyles(t *testing.T, st *store.Store) { t.Helper() started := time.Now() metrics, err := metricsOf(context.Background(), catalog.New(st, slog.New(slog.DiscardHandler))) if err != nil { t.Fatalf("каталог: %v", err) } elapsed := time.Since(started) byStyle := map[catalog.Style]int{} for _, m := range metrics { byStyle[m.Aggregation.Style]++ // Главное свойство правила: свидетельства единогласны. Противоречие — // событие для разбора, а не отказ прогона, поэтому оно печатается с // координатами и валит тест: пока его нет, посылка «род измерим» верна. if m.Aggregation.Conflicting > 0 { t.Errorf("%s: противоречащих часов %d при %d согласных — свидетельства разошлись", m.Metric, m.Aggregation.Conflicting, m.Aggregation.Agreeing) } // Род измеряется только сверкой минутного слоя с часовым. Метрика с // объявленным родом обязана иметь оба слоя: иначе он выведен из // нижнего, а нижний слой HAE — посекундная развёртка, и его сумма // завышена. if m.Aggregation.Style == catalog.Unknown { continue } var hasMinute, hasHour bool for _, l := range m.Layers { hasMinute = hasMinute || l.Layer == string(hae.LayerMinute) hasHour = hasHour || l.Layer == string(hae.LayerHour) } if !hasMinute || !hasHour { t.Errorf("%s: род %v при слоях %+v — измерять было нечем", m.Metric, m.Aggregation.Style, m.Layers) } } // Роды разошлись: правило различает накопительные и мгновенные, а не // сваливает всё в одну кучу и не отвечает `unknown` на весь корпус. if byStyle[catalog.Cumulative] == 0 { t.Error("накопительных метрик не нашлось — правило не различает роды") } if byStyle[catalog.Instant] == 0 { t.Error("мгновенных метрик не нашлось — правило не различает роды") } t.Logf("каталог: метрик %d за %v; накопительных %d, мгновенных %d, неизвестных %d", len(metrics), elapsed.Round(time.Millisecond), byStyle[catalog.Cumulative], byStyle[catalog.Instant], byStyle[catalog.Unknown]) for _, m := range metrics { t.Logf(" %-38s %-10s часов %d, пригодных %d, согласных %d", m.Metric, m.Aggregation.Style, m.Aggregation.Hours, m.Aggregation.Compared, m.Aggregation.Agreeing) } } // countSleepKeys возвращает число различных координат записей сна и число // различных меток начала. Разница между ними и есть то, что теряет ключ по // метке. // // Каталог разрезов — отдельная задача, поэтому здесь перебор по известным // слоям, а не запрос к нему. func countSleepKeys(t *testing.T, st *store.Store) (coords, labels int) { t.Helper() type key struct{ start, end int64 } ctx := context.Background() seenCoord := map[key]struct{}{} seenLabel := map[int64]struct{}{} for _, layer := range []string{"sample", "raw", "minute", "hour", "day"} { hours, err := st.BucketHours(ctx, "sleep_analysis", layer) if err != nil { t.Fatalf("часы объектов: %v", err) } for _, h := range hours { b, err := st.Bucket(ctx, "sleep_analysis", layer, h) if err != nil { t.Fatalf("чтение объекта: %v", err) } for _, p := range b.Points { seenCoord[key{p.Start.UnixNano(), p.End.UnixNano()}] = struct{}{} seenLabel[p.Start.UnixNano()] = struct{}{} } } } return len(seenCoord), len(seenLabel) } func collectBodies(t *testing.T, root string) []string { t.Helper() var out []string err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { if err != nil { return nil //nolint:nilerr // архива может не быть — это не отказ теста } if !info.IsDir() && filepath.Ext(path) == ".gz" { out = append(out, path) } return nil }) if err != nil { return nil } sort.Strings(out) return out } func gunzip(t *testing.T, body []byte) []byte { t.Helper() gz, err := gzip.NewReader(bytes.NewReader(body)) if err != nil { t.Fatalf("распаковка: %v", err) } defer func() { _ = gz.Close() }() out, err := io.ReadAll(gz) if err != nil { t.Fatalf("чтение: %v", err) } return out } // metricsOf — список метрик каталога без версии витрины: версию проверяют // отдельные тесты, остальным нужен только состав ответа. func metricsOf(ctx context.Context, s *catalog.Service) ([]catalog.Metric, error) { snap, err := s.Metrics(ctx) return snap.Metrics, err }