feat: разбор и хранение тренировок и состояния разума
- секции `workouts` и `stateOfMind` покрыты разбором: тренировка лежит одной строкой вместе с маршрутом и внутренними рядами, запись — по ключу `род + id`; миграция 00007 заводит обе таблицы и возвращает в очередь `partial`-доставки с этими ключами - сущность заменяется целиком, но условно: приехавшая побеждает, если не теряет содержания сохранённой (множество ключей и длины верхнеуровневых массивов), а при равном содержании выигрывает версия из более поздней доставки ЖУРНАЛА — «побеждает приехавшая» было бы функцией порядка свёртки, и живая витрина расходилась бы с пересборкой молча - отпечаток витрины покрывает тренировки и записи и снимается одним снимком базы; отчёт `reindex` считает «было и стало» по каждой единице хранения
This commit is contained in:
+130
-5
@@ -221,7 +221,7 @@ func TestFoldЧастичныйРазборВЛоге(t *testing.T) {
|
||||
{"date":"2025-06-05 10:07:00 +0300","qty":8},
|
||||
{"date":"2025-06-05 10:08:00 +0300","qty":9},
|
||||
{"date":"2025-06-05 10:09:00 +0300","qty":10}]}],
|
||||
"stateOfMind":[{"valence":"СЕКРЕТНОЕ-НАСТРОЕНИЕ"}]}}`
|
||||
"ecg":[{"classification":"СЕКРЕТНЫЙ-РИТМ"}]}}`
|
||||
|
||||
deliver(t, arch, st, "d1", "Minutes", "a1", []byte(body))
|
||||
if _, err := f.Fold(ctx, "d1"); err != nil {
|
||||
@@ -229,10 +229,10 @@ func TestFoldЧастичныйРазборВЛоге(t *testing.T) {
|
||||
}
|
||||
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "stateOfMind") {
|
||||
if !strings.Contains(out, "ecg") {
|
||||
t.Error("имени непокрытой секции нет в логе — момент появления новой секции незаметен")
|
||||
}
|
||||
if strings.Contains(out, "СЕКРЕТНОЕ-НАСТРОЕНИЕ") {
|
||||
if strings.Contains(out, "СЕКРЕТНЫЙ-РИТМ") {
|
||||
t.Error("содержимое непокрытой секции утекло в лог")
|
||||
}
|
||||
|
||||
@@ -250,7 +250,132 @@ func TestFoldЧастичныйРазборВЛоге(t *testing.T) {
|
||||
if rec.Level != "INFO" {
|
||||
t.Errorf("уровень %q, ожидался INFO: частичность — не отклонение", rec.Level)
|
||||
}
|
||||
if len(rec.Uncovered) != 1 || rec.Uncovered[0] != "stateOfMind" {
|
||||
t.Errorf("атрибут uncovered = %v, ожидался структурный список из stateOfMind", rec.Uncovered)
|
||||
if len(rec.Uncovered) != 1 || rec.Uncovered[0] != "ecg" {
|
||||
t.Errorf("атрибут uncovered = %v, ожидался структурный список из ecg", rec.Uncovered)
|
||||
}
|
||||
}
|
||||
|
||||
// Содержимое сущности чувствительнее значения точки: маршрут тренировки — это
|
||||
// геотрек до дома, а метки состояния разума — измерение душевного состояния.
|
||||
// Разрешены только координаты: род, идентификатор, интервал.
|
||||
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)
|
||||
ctx := context.Background()
|
||||
|
||||
const full = `{"data":{"workouts":[{"id":"w-открытый","name":"На улице Ходьба",` +
|
||||
`"start":"2025-06-05 10:00:00 +0300","end":"2025-06-05 10:10:00 +0300",` +
|
||||
`"route":[{"latitude":55.987654,"longitude":37.123456},{"latitude":55.987655,"longitude":37.123457}],` +
|
||||
`"totalEnergy":{"qty":404040.4}}],` +
|
||||
`"stateOfMind":[{"id":"e-открытый","start":"2025-06-05T18:00:00Z",` +
|
||||
`"labels":["СЕКРЕТНАЯ-ЭМОЦИЯ"],"valence":0.777777}]}}`
|
||||
|
||||
// Вторая доставка теряет маршрут и меняет значения — та самая ветка, где
|
||||
// пишется WARN об удержанной версии и где велик соблазн приписать «что
|
||||
// именно потерялось».
|
||||
const poorer = `{"data":{"workouts":[{"id":"w-открытый","name":"На улице Ходьба",` +
|
||||
`"start":"2025-06-05 10:00:00 +0300","end":"2025-06-05 10:10:00 +0300",` +
|
||||
`"totalEnergy":{"qty":505050.5}}]}}`
|
||||
|
||||
deliver(t, arch, st, "d1", "Minutes", "auto-1", []byte(full))
|
||||
if _, err := f.Fold(ctx, "d1"); err != nil {
|
||||
t.Fatalf("свёртка первой доставки: %v", err)
|
||||
}
|
||||
deliver(t, arch, st, "d2", "Minutes", "auto-1", []byte(poorer))
|
||||
if _, err := f.Fold(ctx, "d2"); err != nil {
|
||||
t.Fatalf("свёртка второй доставки: %v", err)
|
||||
}
|
||||
|
||||
logged := buf.String()
|
||||
for _, secret := range []string{"55.98", "37.12", "latitude", "СЕКРЕТНАЯ-ЭМОЦИЯ", "404040", "505050", "0.777777"} {
|
||||
if strings.Contains(logged, secret) {
|
||||
t.Errorf("в логе оказалось %q:\n%s", secret, logged)
|
||||
}
|
||||
}
|
||||
// Координаты, наоборот, обязаны быть: без них счётчик удержанных версий не
|
||||
// говорит, какая сущность пострадала.
|
||||
if !strings.Contains(logged, "w-открытый") {
|
||||
t.Error("координат удержанной сущности в логе нет")
|
||||
}
|
||||
if !strings.Contains(logged, "poorer entity version held") {
|
||||
t.Error("удержание обеднённой версии не отмечено записью WARN")
|
||||
}
|
||||
}
|
||||
|
||||
// Счётчики разбора доезжают до лога ручным присваиванием, и забытая строка
|
||||
// молча выключила бы наблюдение — тот самый класс, ради которого счётчики
|
||||
// слияния встроены структурой. Тест закрепляет имена атрибутов.
|
||||
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)
|
||||
|
||||
// Одна годная тренировка, одна без `id`, одна с неразбираемой меткой и один
|
||||
// элемент, не являющийся объектом: три класса пропуска плюс успех.
|
||||
const body = `{"data":{"workouts":[
|
||||
{"id":"w1","start":"2025-06-05 10:00:00 +0300","end":"2025-06-05 10:10:00 +0300"},
|
||||
{"start":"2025-06-05 11:00:00 +0300"},
|
||||
{"id":"w3","start":"позавчера"},
|
||||
"строка вместо объекта"]}}`
|
||||
|
||||
deliver(t, arch, st, "d1", "Minutes", "auto-1", []byte(body))
|
||||
if _, err := f.Fold(context.Background(), "d1"); err != nil {
|
||||
t.Fatalf("свёртка: %v", err)
|
||||
}
|
||||
|
||||
var rec struct {
|
||||
Workouts int `json:"workouts"`
|
||||
WorkoutsWritten int `json:"workouts_written"`
|
||||
Records int `json:"records"`
|
||||
RecordsWritten int `json:"records_written"`
|
||||
EntitiesHeld int `json:"entities_held"`
|
||||
SkippedEntities int `json:"skipped_entities"`
|
||||
}
|
||||
line := strings.TrimSpace(buf.String())
|
||||
if i := strings.LastIndex(line, "\n"); i >= 0 {
|
||||
line = line[i+1:]
|
||||
}
|
||||
if err := json.Unmarshal([]byte(line), &rec); err != nil {
|
||||
t.Fatalf("запись лога не разбирается: %v", err)
|
||||
}
|
||||
|
||||
if rec.Workouts != 1 || rec.WorkoutsWritten != 1 {
|
||||
t.Errorf("тренировок %d, записано %d — ожидалось 1 и 1", rec.Workouts, rec.WorkoutsWritten)
|
||||
}
|
||||
if rec.SkippedEntities != 3 {
|
||||
t.Errorf("пропущено сущностей %d, ожидалось 3 — счётчик не доехал до лога", rec.SkippedEntities)
|
||||
}
|
||||
if rec.Records != 0 || rec.RecordsWritten != 0 || rec.EntitiesHeld != 0 {
|
||||
t.Errorf("лишние счётчики: записей %d/%d, удержано %d",
|
||||
rec.Records, rec.RecordsWritten, rec.EntitiesHeld)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user