добавлен словарь категориальных значений HAE → коды HealthKit

- фазы сна, контекст пульса и имена тренировок попадают в реестр
  `category_value` (миграция 00010): строка хранится дословно, выведенный код
  лежит рядом отдельной записью, а не полем внутри точки
- словарь и синонимы кодов живут в бинаре (`internal/healthkit`); локаль из
  `Accept-Language` сужает поиск, но в ключ реестра не входит — заголовков в
  сыром архиве нет
- наблюдение входит в отпечаток витрины, выведенный код — нет: он производная
  от словаря, а не от журнала
This commit is contained in:
av
2026-08-04 07:32:19 +03:00
parent eb3fca77ee
commit 1b649ba3d5
40 changed files with 4317 additions and 42 deletions
+11 -3
View File
@@ -179,9 +179,14 @@ type report struct {
// единица, которой нет в счётчиках, делает расхождение безадресным.
sourceWorkouts int64
sourceRecords int64
sourceBefore int64
sourceAfter int64
sourceMissing bool
// sourceCategories — то же «было» для реестра категориальных значений.
// Перечень единиц хранения закрытый, и он пополняется ТЕМ ЖЕ изменением,
// которое заводит единицу: не внесённая сюда, она молчит ровно там, где
// расхождение впервые становится заметным.
sourceCategories int64
sourceBefore int64
sourceAfter int64
sourceMissing bool
}
// rebuild собирает витрину в промежуточный файл и переименовывает его в файл
@@ -237,6 +242,9 @@ func rebuild(ctx context.Context, cfg *config.Config, t target, log *slog.Logger
if rep.sourceRecords, err = src.CountRecords(ctx); err != nil {
return canceledOr(rep, err, stopped)
}
if rep.sourceCategories, err = src.CountCategoryValues(ctx); err != nil {
return canceledOr(rep, err, stopped)
}
}
removeDB(t.partial)
+23
View File
@@ -66,6 +66,7 @@ func writeReport(w io.Writer, r report) {
p(" объектов: %d", r.replay.Buckets)
p(" тренировок: %d", r.replay.Workouts)
p(" записей: %d", r.replay.Records)
p(" строк реестра категориальных значений: %d", r.replay.Categories)
p("")
p("рабочей базы %s нет — сверять не с чем, а заголовки доставок", r.dbPath)
p("не восстанавливаются: в архиве их нет.")
@@ -76,6 +77,8 @@ func writeReport(w io.Writer, r report) {
p(" объектов: было %d, стало %d", r.sourceBuckets, r.replay.Buckets)
p(" тренировок: было %d, стало %d", r.sourceWorkouts, r.replay.Workouts)
p(" записей: было %d, стало %d", r.sourceRecords, r.replay.Records)
p(" строк реестра категориальных значений: было %d, стало %d",
r.sourceCategories, r.replay.Categories)
p("")
p(" отпечаток рабочей: %s", r.sourcePrint)
p(" отпечаток пересобранной: %s", r.replay.Fingerprint)
@@ -89,6 +92,26 @@ func writeReport(w io.Writer, r report) {
p(" ожидаемые причины: исправленный разбор; покрытая разбором новая")
p(" секция (её единиц хранения в рабочей базе нет по построению);")
p(" признак sealed не переносится (правила его выставления ещё нет)")
if r.sourceCategories < r.replay.Categories {
// Класс назван отдельно от факта расхождения: реестр появился
// вместе с бинарём, и у витрины, свёрнутой прежним, его нет по
// построению. Не назвав это, отчёт приучает человека
// игнорировать расхождение — то есть обесценивает оракул ровно
// там, где по нему принимается необратимое решение.
//
// Условие — НЕПОЛНОТА, а не пустота. Между выкаткой и прогоном
// проходят дни: воркер успевает набрать частые значения (фазы
// сна, контекст пульса) и не успевает редкие — имя тренировки,
// которая с тех пор не повторялась. Проверка «в рабочей базе
// реестра нет вовсе» такое состояние не ловила бы, и человек
// получил бы безадресное «разошлись» при совпавших числах
// объектов, тренировок и записей.
p(" РЕЕСТР НЕПОЛОН: строк категориальных значений в рабочей базе %d,",
r.sourceCategories)
p(" в пересобранной %d — реестр наполняется по мере свёртки, а целиком",
r.replay.Categories)
p(" его даёт только пересборка. Расхождение объясняется этим и лечится ею же")
}
if partialJournal {
p(" ВНИМАНИЕ: часть журнала не прочитана — расхождение может")
p(" объясняться этим, а не разбором")
+106
View File
@@ -337,3 +337,109 @@ func TestОтчётВсегдаНазываетУдержанныеВерсии(
}
}
}
// Реестр категориальных значений — четвёртая единица хранения витрины, и у
// витрины, свёрнутой прежним бинарём, его нет по построению. Расхождение
// отпечатков по нему одному законно, и отчёт обязан назвать это классом, а не
// оставить человека с безадресным «не совпало»: числа объектов, тренировок и
// записей при этом не меняются вовсе, а решение о подмене базы необратимо.
func TestОтчётНазываетПоявившийсяРеестр(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
writeReport(&buf, report{
replay: replay.Report{
Bodies: 116,
Outcome: replay.Outcome{Folded: 116},
Buckets: 2049, Workouts: 2, Records: 2, Categories: 11,
Fingerprint: "aaaa",
},
target: "/data/healthlog.db.rebuild",
dbPath: "/data/healthlog.db",
sourcePrint: "bbbb",
sourceBuckets: 2049,
sourceWorkouts: 2,
sourceRecords: 2,
sourceCategories: 0,
sourceBefore: 116,
sourceAfter: 116,
})
out := buf.String()
for _, want := range []string{
"строк реестра категориальных значений: было 0, стало 11",
"РЕЕСТР НЕПОЛОН",
"лечится ею же",
} {
if !strings.Contains(out, want) {
t.Errorf("отчёт не содержит %q:\n%s", want, out)
}
}
// Сами строки реестра — данные о здоровье наравне со значением точки:
// отчёт отвечает счётом, а не перечислением.
for _, forbidden := range []string{"Во сне", "Сидячий образ жизни", "HKCategoryValue"} {
if strings.Contains(out, forbidden) {
t.Errorf("отчёт содержит наблюдённую строку %q", forbidden)
}
}
}
// Реестр рабочей витрины непуст, но неполон — штатное состояние через сутки
// после выкатки: частые значения воркер набрал, редкое имя тренировки с тех пор
// не повторялось. Класс обязан называться и здесь, иначе человек получит
// безадресное «разошлись» при совпавших числах объектов, тренировок и записей —
// и научится игнорировать строку, по которой принимает необратимое решение.
func TestОтчётНазываетНеполныйРеестр(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
writeReport(&buf, report{
replay: replay.Report{
Bodies: 116,
Outcome: replay.Outcome{Folded: 116},
Buckets: 2049, Workouts: 2, Records: 2, Categories: 11,
Fingerprint: "aaaa",
},
target: "/data/healthlog.db.rebuild",
dbPath: "/data/healthlog.db",
sourcePrint: "bbbb",
sourceBuckets: 2049,
sourceWorkouts: 2,
sourceRecords: 2,
sourceCategories: 5,
sourceBefore: 116,
sourceAfter: 116,
})
out := buf.String()
for _, want := range []string{"РЕЕСТР НЕПОЛОН", "в рабочей базе 5", "в пересобранной 11"} {
if !strings.Contains(out, want) {
t.Errorf("отчёт не содержит %q:\n%s", want, out)
}
}
}
// Совпавший реестр отдельным классом не объявляется: иначе строка звучала бы
// при каждом прогоне и перестала бы что-либо значить.
func TestОтчётНеОбъявляетРеестрПоявившимсяБезПричины(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
writeReport(&buf, report{
replay: replay.Report{
Bodies: 10,
Outcome: replay.Outcome{Folded: 10},
Buckets: 5, Categories: 11, Fingerprint: "aaaa",
},
target: "/data/healthlog.db.rebuild",
dbPath: "/data/healthlog.db",
sourcePrint: "bbbb",
sourceBuckets: 4,
sourceCategories: 11,
sourceBefore: 10,
sourceAfter: 10,
})
if out := buf.String(); strings.Contains(out, "РЕЕСТР НЕПОЛОН") {
t.Errorf("класс объявлен при совпавшем реестре рабочей витрины:\n%s", out)
}
}