feat: разбор и хранение тренировок и состояния разума

- секции `workouts` и `stateOfMind` покрыты разбором: тренировка лежит одной
  строкой вместе с маршрутом и внутренними рядами, запись — по ключу `род + id`;
  миграция 00007 заводит обе таблицы и возвращает в очередь `partial`-доставки
  с этими ключами
- сущность заменяется целиком, но условно: приехавшая побеждает, если не теряет
  содержания сохранённой (множество ключей и длины верхнеуровневых массивов), а
  при равном содержании выигрывает версия из более поздней доставки ЖУРНАЛА —
  «побеждает приехавшая» было бы функцией порядка свёртки, и живая витрина
  расходилась бы с пересборкой молча
- отпечаток витрины покрывает тренировки и записи и снимается одним снимком
  базы; отчёт `reindex` считает «было и стало» по каждой единице хранения
This commit is contained in:
av
2026-08-02 13:05:16 +03:00
parent c28de9796e
commit f8200f7f80
47 changed files with 5817 additions and 301 deletions
+74
View File
@@ -88,3 +88,77 @@ func TestMigrationПрежниеParsedСтановятсяPending(t *testing.T)
}
}
}
// Миграция 00007 исполняет правило «покрыли секцию — пересверните»: список
// непокрытых ключей это снимок покрытия на момент свёртки, и доставки,
// свёрнутые до того, как workouts и stateOfMind стали покрытыми, остались бы
// `partial` со старым списком навсегда — а ретеншен вечно щадил бы их тела.
//
// Перевод ТОЧЕЧНЫЙ: доставка, у которой непокрыта только `ecg`, пересворачивать
// нечего, и трогать её значило бы гонять весь архив на каждую новую секцию.
func TestMigrationПокрытыеСекцииВозвращаютсяВОчередь(t *testing.T) {
db, err := sqlx.Connect("sqlite", dsn(filepath.Join(t.TempDir(), "healthlog.db")))
if err != nil {
t.Fatalf("открытие базы: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
sub, err := fs.Sub(migrationsFS, "migrations")
if err != nil {
t.Fatalf("миграции: %v", err)
}
p, err := goose.NewProvider(goose.DialectSQLite3, db.DB, sub)
if err != nil {
t.Fatalf("провайдер: %v", err)
}
ctx := context.Background()
if _, err := p.UpTo(ctx, 6); err != nil {
t.Fatalf("миграция до 6: %v", err)
}
const insert = `
INSERT INTO delivery (id, received_at, automation_name, automation_id,
aggregation, period, session_id, bytes, sha256, raw_path,
parse_status, points, uncovered_sections)
VALUES (?, '2025-06-05T10:00:00Z', '', '', '', '', '', 0, '-', '-', ?, 0, ?)`
cases := []struct {
id string
status string
uncovered string
want string
}{
{"d-workouts", ParsePartial, `["workouts"]`, ParsePending},
{"d-mind", ParsePartial, `["stateOfMind"]`, ParsePending},
{"d-both", ParsePartial, `["stateOfMind","workouts"]`, ParsePending},
{"d-mixed", ParsePartial, `["ecg","workouts"]`, ParsePending},
// Ничего из ставшего покрытым: трогать нечего.
{"d-ecg", ParsePartial, `["ecg"]`, ParsePartial},
// Подстрока имени секции — не имя секции: отбор идёт по элементу
// массива, иначе чужое тело управляло бы тем, что мы пересворачиваем.
{"d-lookalike", ParsePartial, `["myworkoutsx"]`, ParsePartial},
// Статус `failed` возвращает только пересборка, а `parsed` этой
// миграцией не трогается: у него пустой список непокрытых.
{"d-failed", ParseFailed, `["workouts"]`, ParseFailed},
{"d-parsed", ParseDone, `[]`, ParseDone},
}
for _, c := range cases {
if _, err := db.ExecContext(ctx, insert, c.id, c.status, c.uncovered); err != nil {
t.Fatalf("вставка %s: %v", c.id, err)
}
}
if _, err := p.UpTo(ctx, 7); err != nil {
t.Fatalf("миграция до 7: %v", err)
}
for _, c := range cases {
var got string
if err := db.GetContext(ctx, &got, `SELECT parse_status FROM delivery WHERE id = ?`, c.id); err != nil {
t.Fatalf("чтение %s: %v", c.id, err)
}
if got != c.want {
t.Errorf("%s: статус %q, ожидался %q (непокрытые %s)", c.id, got, c.want, c.uncovered)
}
}
}