- отношение победы было нетранзитивным: полнота (частичный порядок) плюс тай-брейк (тотальный) в попарной свёртке давали цикл, из-за которого одна и та же доставка меняла содержимое объекта при каждой пересборке - надмножество побеждает только при совпадении значений общих содержательных ключей: иначе точка без единого измерения вытесняла измерение - Less стал тотальным, isEmpty не материализует значение, имя метрики в координате столкновения обрезается, отпечаток витрины включает units и sealed - на живом архиве строгий no-op: 1737 объектов, содержимое совпало побайтово
185 lines
7.1 KiB
Go
185 lines
7.1 KiB
Go
package fold_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.vakhrushev.me/av/healthlog/internal/archive"
|
|
"git.vakhrushev.me/av/healthlog/internal/fold"
|
|
"git.vakhrushev.me/av/healthlog/internal/store"
|
|
)
|
|
|
|
// Данные о здоровье чувствительнее токенов, и требование «значения точек не в
|
|
// логах» до сих пор не проверялось ничем: все тестовые логгеры выбрасывали
|
|
// записи. Тест ловит записи и смотрит на них.
|
|
func TestFoldНеПишетЗначенийВЛог(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var buf bytes.Buffer
|
|
log := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
|
|
|
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() })
|
|
|
|
f := fold.New(arch, st, 0, log)
|
|
|
|
// Значения и имя устройства выбраны так, чтобы их нельзя было спутать ни с
|
|
// чем: если они окажутся в логе, это будет видно.
|
|
body := []byte(`{"data":{"metrics":[{"name":"step_count","units":"count","data":[` +
|
|
`{"date":"2025-06-05 10:00:00 +0300","qty":424242.7,"source":"Секретные Часы Антона"},` +
|
|
`{"date":"2025-06-05 10:01:00 +0300","qty":313131.9,"source":"Секретные Часы Антона"},` +
|
|
`{"date":"2025-06-05 10:02:00 +0300","qty":151515.1,"source":"Секретные Часы Антона"}` +
|
|
`]}]}}`)
|
|
|
|
deliver(t, arch, st, "d1", "Minutes", "auto-1", body)
|
|
if _, err := f.Fold(context.Background(), "d1"); err != nil {
|
|
t.Fatalf("свёртка: %v", err)
|
|
}
|
|
|
|
logged := buf.String()
|
|
if logged == "" {
|
|
t.Fatal("свёртка не записала ни одной строки — чекпоинт молчит")
|
|
}
|
|
|
|
for _, secret := range []string{"424242", "313131", "151515", "Секретные Часы"} {
|
|
if strings.Contains(logged, secret) {
|
|
t.Errorf("в логе оказалось %q:\n%s", secret, logged)
|
|
}
|
|
}
|
|
|
|
// И одновременно — чекпоинт обязан нести счётчики, иначе он бесполезен.
|
|
var rec map[string]any
|
|
for line := range strings.SplitSeq(strings.TrimSpace(logged), "\n") {
|
|
if err := json.Unmarshal([]byte(line), &rec); err != nil {
|
|
t.Fatalf("строка лога не JSON: %v", err)
|
|
}
|
|
if rec["msg"] == "delivery folded" {
|
|
break
|
|
}
|
|
}
|
|
for _, attr := range []string{"delivery_id", "points", "stored", "buckets", "layer", "skipped"} {
|
|
if _, ok := rec[attr]; !ok {
|
|
t.Errorf("в записи нет атрибута %q: %v", attr, rec)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Доставка, у которой отброшены ВСЕ точки, — это сломавшийся формат, а не
|
|
// штатная работа. Без WARN смена формата метки выглядела бы как здоровый
|
|
// поток: 200, parsed, INFO, points=0.
|
|
func TestFoldВсеТочкиОтброшеныДаётWarn(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var buf bytes.Buffer
|
|
log := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
|
|
|
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() })
|
|
|
|
f := fold.New(arch, st, 0, log)
|
|
|
|
// Метки в формате, которого разбор не знает: HAE сменил формат.
|
|
body := []byte(`{"data":{"metrics":[{"name":"step_count","units":"count","data":[` +
|
|
`{"date":"2025-06-05T10:00:00Z","qty":1},{"date":"2025-06-05T10:01:00Z","qty":2}` +
|
|
`]}]}}`)
|
|
|
|
deliver(t, arch, st, "d1", "Minutes", "auto-1", body)
|
|
if _, err := f.Fold(context.Background(), "d1"); err != nil {
|
|
t.Fatalf("свёртка: %v", err)
|
|
}
|
|
|
|
if !strings.Contains(buf.String(), `"level":"WARN"`) {
|
|
t.Errorf("доставка без единой сохранённой точки записана не как WARN:\n%s", buf.String())
|
|
}
|
|
}
|
|
|
|
// Несравнимые наборы полей — то самое событие, ради наблюдения за которым
|
|
// объединение полей не реализовано вовсе. Если оно когда-нибудь наступит, его
|
|
// обязано быть видно, а не додумано задним числом.
|
|
func TestFoldНесравнимыеНаборыДаютWarn(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var buf bytes.Buffer
|
|
log := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
|
|
|
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() })
|
|
|
|
f := fold.New(arch, st, 0, log)
|
|
ctx := context.Background()
|
|
|
|
const body = `{"data":{"metrics":[{"name":"blood_glucose","units":"mg/dL","data":[` +
|
|
`{"date":"2025-06-05 10:00:00 +0300",%s}` +
|
|
`]}]}}`
|
|
|
|
deliver(t, arch, st, "d1", "Minutes", "auto-1", []byte(fmt.Sprintf(body, `"qty":5.1`)))
|
|
if _, err := f.Fold(ctx, "d1"); err != nil {
|
|
t.Fatalf("первая свёртка: %v", err)
|
|
}
|
|
buf.Reset()
|
|
|
|
deliver(t, arch, st, "d2", "Minutes", "auto-1", []byte(fmt.Sprintf(body, `"mealTime":"До еды"`)))
|
|
stats, err := f.Fold(ctx, "d2")
|
|
if err != nil {
|
|
t.Fatalf("вторая свёртка: %v", err)
|
|
}
|
|
if stats.Incomparable != 1 {
|
|
t.Fatalf("несравнимых %d, ожидался 1: тест проверяет не то", stats.Incomparable)
|
|
}
|
|
|
|
var rec map[string]any
|
|
if err := json.Unmarshal([]byte(strings.TrimSpace(buf.String())), &rec); err != nil {
|
|
t.Fatalf("строка лога не JSON: %v\n%s", err, buf.String())
|
|
}
|
|
if rec["level"] != "WARN" {
|
|
t.Errorf("уровень %v, ожидался WARN:\n%s", rec["level"], buf.String())
|
|
}
|
|
if rec["msg"] != "delivery folded, incomparable point fields" {
|
|
t.Errorf("сообщение %v не называет событие", rec["msg"])
|
|
}
|
|
if rec["incomparable"] != float64(1) {
|
|
t.Errorf("счётчик несравнимых %v, ожидался 1", rec["incomparable"])
|
|
}
|
|
// Координаты объекта — не значения: метрика, слой и час.
|
|
at, _ := rec["incomparable_at"].(string)
|
|
if !strings.Contains(at, "blood_glucose/minute@") {
|
|
t.Errorf("координаты объекта в записи не те: %q", at)
|
|
}
|
|
// И ни одного значения точки: данные о здоровье чувствительнее токенов.
|
|
for _, secret := range []string{"5.1", "До еды"} {
|
|
if strings.Contains(buf.String(), secret) {
|
|
t.Errorf("в логе оказалось значение точки %q:\n%s", secret, buf.String())
|
|
}
|
|
}
|
|
}
|