добавлен словарь категориальных значений HAE → коды HealthKit
- фазы сна, контекст пульса и имена тренировок попадают в реестр `category_value` (миграция 00010): строка хранится дословно, выведенный код лежит рядом отдельной записью, а не полем внутри точки - словарь и синонимы кодов живут в бинаре (`internal/healthkit`); локаль из `Accept-Language` сужает поиск, но в ключ реестра не входит — заголовков в сыром архиве нет - наблюдение входит в отпечаток витрины, выведенный код — нет: он производная от словаря, а не от журнала
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
package hae
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.vakhrushev.me/av/healthlog/internal/healthkit"
|
||||
)
|
||||
|
||||
// Categorical — наблюдённое категориальное значение: строка перечислимого поля
|
||||
// и выведенный для неё код HealthKit.
|
||||
//
|
||||
// Это НАБЛЮДЕНИЕ, а не копия данных: сама строка остаётся в содержимом точки
|
||||
// дословно, а наблюдение говорит «такое значение поток приносил, и вот его
|
||||
// стабильный код». Пустой код означает «словарь этой строки не знает» — и
|
||||
// перечень таких строк есть заявка на пополнение словаря.
|
||||
type Categorical struct {
|
||||
// Metric — имя метрики или секции, ТО ЖЕ, которым адресуется единица
|
||||
// хранения (`sleep_analysis_summary` после разделения схем, `workouts` у
|
||||
// тренировок). Второе имя для того же понятия развело бы наблюдение и объект
|
||||
// по разным ключам.
|
||||
Metric string
|
||||
// Field — имя поля внутри точки или сущности, дословно как у HAE.
|
||||
Field string
|
||||
// Value — строка, как прислал HAE.
|
||||
Value string
|
||||
// Code — канонический код HealthKit либо пустая строка.
|
||||
Code string
|
||||
}
|
||||
|
||||
// Имена категориальных полей — дословно как у HAE.
|
||||
const (
|
||||
fieldValue = "value"
|
||||
fieldContext = "context"
|
||||
fieldName = "name"
|
||||
)
|
||||
|
||||
// heartRateMetric — имя метрики пульса у HAE.
|
||||
const heartRateMetric = "heart_rate"
|
||||
|
||||
// pointCategoricalFields — какие поля ТОЧКИ несут перечислимое значение.
|
||||
//
|
||||
// Список объявлен явно, а не выведен из формы значения: строк в точке много
|
||||
// (`date`, `start`, `source`), и правило «всякая строка категориальна» завело бы
|
||||
// в реестр метки времени и имена устройств. Состав измерен на живом потоке
|
||||
// (находка 37); новое поле — одна строка здесь и запись в словаре.
|
||||
var pointCategoricalFields = map[string][]string{
|
||||
sleepMetric: {fieldValue},
|
||||
heartRateMetric: {fieldContext},
|
||||
}
|
||||
|
||||
// Границы наблюдений одной доставки.
|
||||
//
|
||||
// Тело контролирует отправитель целиком: без границы одна доставка кладёт в
|
||||
// витрину сколько угодно строк, а строки эти уезжают в первичный ключ.
|
||||
//
|
||||
// Числа названы измерением, а не аналогией с 32/64 у имён непокрытых секций:
|
||||
// там имена короткие и латинские, здесь — русские фразы в UTF-8. На живом
|
||||
// потоке различных значений по всем трём полям около одиннадцати, самое длинное
|
||||
// — «Сидячий образ жизни», 36 байт (находка 37). Предел в 32 байта отбросил бы
|
||||
// две из трёх измеренных строк контекста пульса.
|
||||
const (
|
||||
maxCategoricalValues = 64
|
||||
maxCategoricalValueLen = 128
|
||||
)
|
||||
|
||||
// categoricalKey — ключ наблюдения. Совпадает с ключом реестра в витрине: два
|
||||
// разных ключа на одно понятие разошлись бы при первом же поле с одинаковым
|
||||
// именем у двух метрик.
|
||||
type categoricalKey struct {
|
||||
metric string
|
||||
field string
|
||||
value string
|
||||
}
|
||||
|
||||
// categoricals — сборщик наблюдений одной доставки.
|
||||
//
|
||||
// Дедупликация обязательна: `context` повторяется в каждой точке пульса, и без
|
||||
// множества доставка на две тысячи точек дала бы две тысячи одинаковых
|
||||
// наблюдений. Локаль хранится здесь, а не в наблюдении: она сужает поиск по
|
||||
// словарю и в ключ не входит — заголовков в сыром архиве нет, и ключ с локалью
|
||||
// сделал бы состояние функцией от того, уцелела ли учётная строка.
|
||||
//
|
||||
// Набор ограничен ПРИ ВСТАВКЕ, а не при выдаче, и это измеренное решение, а не
|
||||
// аккуратность. Накопитель без границы растёт по числу РАЗЛИЧНЫХ строк тела, а
|
||||
// их контролирует отправитель: тело в 60 МиБ из миллиона различных значений
|
||||
// (предел приёма — 64 МиБ) поднимало пик процесса с 780 до 1002 МиБ. Лимита
|
||||
// памяти у контейнера нет, OOM в фоновой горутине свёртки не перехватывается, а
|
||||
// `restart: unless-stopped` поднимает процесс — и первый же проход берёт ту же
|
||||
// доставку из архива. Приём при этом стоит, а телефон доставку не перешлёт.
|
||||
// Правило то же, что уже действует в этом пакете для имён непокрытых секций.
|
||||
//
|
||||
// Держатся 64 НАИМЕНЬШИХ ключа: усечение остаётся функцией МНОЖЕСТВА, а не
|
||||
// порядка элементов на проводе. Порядок ключей у HAE нестабилен (находка 2), и
|
||||
// «первые 64 по ходу разбора» давали бы разный реестр на переприсланном том же
|
||||
// содержимом — расхождение вышло бы как «пересборка не сошлась», без адреса.
|
||||
//
|
||||
// Срез, а не куча: элементов 64, вставка двоичным поиском стоит дешевле
|
||||
// поддержания инварианта кучи, а отсортированный срез заодно и есть готовый
|
||||
// ответ `result`.
|
||||
type categoricals struct {
|
||||
locale string
|
||||
// seen — членство, kept — те же ключи в порядке возрастания. Две структуры
|
||||
// на одно множество: карта отвечает «видели ли», срез — «кто наибольший»,
|
||||
// и оба вопроса задаются на каждое вхождение.
|
||||
seen map[categoricalKey]struct{}
|
||||
kept []categoricalKey
|
||||
dropped int
|
||||
}
|
||||
|
||||
func newCategoricals(locale string) *categoricals {
|
||||
return &categoricals{
|
||||
locale: locale,
|
||||
seen: make(map[categoricalKey]struct{}, maxCategoricalValues),
|
||||
kept: make([]categoricalKey, 0, maxCategoricalValues),
|
||||
}
|
||||
}
|
||||
|
||||
// less задаёт порядок ключей — он же порядок выдачи и он же правило усечения.
|
||||
func (a categoricalKey) less(b categoricalKey) bool {
|
||||
if a.metric != b.metric {
|
||||
return a.metric < b.metric
|
||||
}
|
||||
if a.field != b.field {
|
||||
return a.field < b.field
|
||||
}
|
||||
return a.value < b.value
|
||||
}
|
||||
|
||||
// add записывает наблюдение, если строка на него годится.
|
||||
//
|
||||
// Пустая строка наблюдением не считается: сказать о данных ей нечего, а в
|
||||
// счётчике строк без кода она сидела бы вечно — тренировка без `name` даёт
|
||||
// ровно её (мягкое чтение заголовка сущности превращает значение не того типа в
|
||||
// пустую строку).
|
||||
//
|
||||
// Слишком длинное значение ОТБРАСЫВАЕТСЯ, а не обрезается: обрезанная строка
|
||||
// неотличима от настоящей и попала бы в ключ реестра самостоятельным значением.
|
||||
// Сама точка при этом хранится целиком — теряется наблюдение, а не данные.
|
||||
func (c *categoricals) add(metric, field, value string) {
|
||||
if value == "" {
|
||||
return
|
||||
}
|
||||
if len(value) > maxCategoricalValueLen {
|
||||
c.dropped++
|
||||
return
|
||||
}
|
||||
|
||||
key := categoricalKey{metric: metric, field: field, value: value}
|
||||
if _, ok := c.seen[key]; ok {
|
||||
return
|
||||
}
|
||||
|
||||
if len(c.kept) >= maxCategoricalValues {
|
||||
// Набор полон. Ключ больше наибольшего удержанного — он и есть
|
||||
// отброшенный; иначе вытесняем наибольший, а отброшенным становится он.
|
||||
last := c.kept[len(c.kept)-1]
|
||||
if !key.less(last) {
|
||||
c.dropped++
|
||||
return
|
||||
}
|
||||
delete(c.seen, last)
|
||||
c.kept = c.kept[:len(c.kept)-1]
|
||||
c.dropped++
|
||||
}
|
||||
|
||||
at := sort.Search(len(c.kept), func(i int) bool { return key.less(c.kept[i]) })
|
||||
c.kept = append(c.kept, categoricalKey{})
|
||||
copy(c.kept[at+1:], c.kept[at:])
|
||||
c.kept[at] = key
|
||||
c.seen[key] = struct{}{}
|
||||
}
|
||||
|
||||
// addPoint снимает с точки объявленные для её метрики поля.
|
||||
//
|
||||
// Значение читается из уже разобранного заголовка точки и принимается только
|
||||
// как JSON-строка: объяви поле `string` в самом заголовке — и точка, у которой
|
||||
// `value` пришло числом, перестала бы разбираться вовсе. Это был бы новый путь
|
||||
// потери данных ради удобства структуры.
|
||||
func (c *categoricals) addPoint(metric string, head *pointHead) {
|
||||
for _, field := range pointCategoricalFields[metric] {
|
||||
var raw *json.RawMessage
|
||||
switch field {
|
||||
case fieldValue:
|
||||
raw = head.Value
|
||||
case fieldContext:
|
||||
raw = head.Context
|
||||
}
|
||||
if raw == nil {
|
||||
continue
|
||||
}
|
||||
if s, ok := jsonString(*raw); ok {
|
||||
c.add(metric, field, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// result отдаёт наблюдения доставки: отсортированные, усечённые границей и с
|
||||
// выведенными кодами.
|
||||
//
|
||||
// Порядок и усечение — функция МНОЖЕСТВА, а не порядка элементов на проводе; за
|
||||
// это отвечает add, здесь набор уже готов.
|
||||
//
|
||||
// Код выводится здесь, а не при добавлении: словарь зовётся по разу на
|
||||
// РАЗЛИЧНОЕ удержанное значение, а не по разу на точку. На теле в миллион
|
||||
// точек это 64 обращения к карте вместо миллиона.
|
||||
func (c *categoricals) result() (out []Categorical, unknown, dropped int) {
|
||||
dropped = c.dropped
|
||||
|
||||
out = make([]Categorical, 0, len(c.kept))
|
||||
for _, k := range c.kept {
|
||||
code := healthkit.Code(c.locale, k.value)
|
||||
if code == "" {
|
||||
unknown++
|
||||
}
|
||||
out = append(out, Categorical{Metric: k.metric, Field: k.field, Value: k.value, Code: code})
|
||||
}
|
||||
return out, unknown, dropped
|
||||
}
|
||||
|
||||
// jsonString читает значение как строку JSON, не считая строкой ничего другого.
|
||||
//
|
||||
// Проверка первого байта — та же дисциплина, что в jsonNumber: полагаться на
|
||||
// тип-приёмник значило бы получить разное поведение от невидимой детали, а
|
||||
// молчаливое приведение числа к строке выдумало бы за источник значение,
|
||||
// которого он не присылал.
|
||||
func jsonString(raw json.RawMessage) (string, bool) {
|
||||
if len(raw) == 0 || raw[0] != '"' {
|
||||
return "", false
|
||||
}
|
||||
var s string
|
||||
if err := json.Unmarshal(raw, &s); err != nil {
|
||||
return "", false
|
||||
}
|
||||
// Значение, которое `encoding/json` ЗАМЕНИЛ, наблюдением не считается.
|
||||
//
|
||||
// Ошибки он на этом не даёт: негодную последовательность — сырой байт 0xFF,
|
||||
// одинокий суррогат `\ud800` — он молча меняет на U+FFFD, и строка на выходе
|
||||
// оказывается валидным UTF-8, но уже не равной пришедшим байтам (измерено:
|
||||
// 0xFF даёт "\uFFFDВо сне", `utf8.ValidString` отвечает true). Проверять
|
||||
// поэтому надо не годность результата, а его НЕТРОНУТОСТЬ.
|
||||
//
|
||||
// Реестр требует хранить значение дословно; подменённое осело бы в первичном
|
||||
// ключе таблицы, у которой нет обслуживания, и кода не получило бы никогда.
|
||||
// Точка при этом хранится целиком — теряется наблюдение, а не данные, и это
|
||||
// та же цена, что у непомерной длины.
|
||||
//
|
||||
// Цена правила названа: настоящий U+FFFD в значении тоже не станет
|
||||
// наблюдением. Перечислимые значения HealthKit — слова человеческого языка,
|
||||
// символа замены в них не бывает, а ошибка направлена в безопасную сторону.
|
||||
if strings.ContainsRune(s, utf8.RuneError) {
|
||||
return "", false
|
||||
}
|
||||
return s, true
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
package hae_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.vakhrushev.me/av/healthlog/internal/hae"
|
||||
)
|
||||
|
||||
// Строки берутся ИЗ пакета testdata, а не из литерала теста: словарь набран
|
||||
// литералами Go, а сверяться ему предстоит с байтами тела. Apple шлёт внутри
|
||||
// своих строк неразрывные пробелы (находка 24), и литерал, набранный руками,
|
||||
// такую подмену не воспроизводит — совпадение теста с реализацией доказывало бы
|
||||
// только согласие автора с самим собой.
|
||||
func TestParseКатегориальныеЗначенияРеальногоПакета(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := readTestdata(t, "sparse_sleep.json")
|
||||
res, err := hae.Parse(body, hae.Meta{Locale: "ru", FallbackLayer: hae.LayerMinute})
|
||||
if err != nil {
|
||||
t.Fatalf("разбор: %v", err)
|
||||
}
|
||||
|
||||
want := []hae.Categorical{
|
||||
{Metric: "heart_rate", Field: "context", Value: "Не задано", Code: ""},
|
||||
{Metric: "sleep_analysis", Field: "value", Value: "В кровати", Code: "HKCategoryValueSleepAnalysisInBed"},
|
||||
{Metric: "sleep_analysis", Field: "value", Value: "Во сне", Code: "HKCategoryValueSleepAnalysisAsleepUnspecified"},
|
||||
}
|
||||
assertCategoricals(t, res.Categoricals, want)
|
||||
|
||||
// Строка, из которой выведен код, обязана остаться в точке ДОСЛОВНО: код
|
||||
// приписывается, а не подменяет.
|
||||
assertRawContains(t, res.Points, "sleep_analysis", `"value": "Во сне"`)
|
||||
|
||||
// Контекст пульса словарём не покрыт намеренно (см. healthkit): его строка
|
||||
// видна реестром с пустым кодом и считается счётчиком.
|
||||
if res.CategoricalUnknown != 1 {
|
||||
t.Errorf("строк без кода %d, ожидалась 1", res.CategoricalUnknown)
|
||||
}
|
||||
if res.CategoricalDropped != 0 {
|
||||
t.Errorf("отброшено %d, ожидался ноль", res.CategoricalDropped)
|
||||
}
|
||||
}
|
||||
|
||||
// Тысяча точек с одним контекстом даёт одно наблюдение: без дедупликации
|
||||
// доставка пульса за сутки положила бы в витрину тысячи одинаковых строк.
|
||||
func TestParseПовторСтрокиНеЗадваиваетНаблюдение(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := readTestdata(t, "raw.json")
|
||||
res, err := hae.Parse(body, hae.Meta{Locale: "ru", FallbackLayer: hae.LayerMinute})
|
||||
if err != nil {
|
||||
t.Fatalf("разбор: %v", err)
|
||||
}
|
||||
|
||||
if len(res.Points) < 10 {
|
||||
t.Fatalf("в пакете %d точек — тест потерял смысл", len(res.Points))
|
||||
}
|
||||
seen := make(map[string]int)
|
||||
for _, c := range res.Categoricals {
|
||||
seen[c.Metric+"/"+c.Field+"/"+c.Value]++
|
||||
}
|
||||
for key, n := range seen {
|
||||
if n != 1 {
|
||||
t.Errorf("наблюдение %s встретилось %d раз", key, n)
|
||||
}
|
||||
}
|
||||
t.Logf("точек %d, различных наблюдений %d", len(res.Points), len(res.Categoricals))
|
||||
}
|
||||
|
||||
func TestParseИмяТренировкиПопадаетВНаблюдения(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := readTestdata(t, "workout_route.json")
|
||||
res, err := hae.Parse(body, hae.Meta{Locale: "ru", FallbackLayer: hae.LayerMinute})
|
||||
if err != nil {
|
||||
t.Fatalf("разбор: %v", err)
|
||||
}
|
||||
if len(res.Workouts) == 0 {
|
||||
t.Fatal("в пакете нет тренировок — тест потерял смысл")
|
||||
}
|
||||
|
||||
var names []string
|
||||
for _, c := range res.Categoricals {
|
||||
if c.Metric == "workouts" && c.Field == "name" {
|
||||
if c.Code != "" {
|
||||
t.Errorf("имя тренировки %q получило код %q — словарь их не покрывает", c.Value, c.Code)
|
||||
}
|
||||
names = append(names, c.Value)
|
||||
}
|
||||
}
|
||||
if len(names) == 0 {
|
||||
t.Fatal("имена тренировок в наблюдения не попали")
|
||||
}
|
||||
// Содержимое сущности не изменено: имя остаётся в нём дословно.
|
||||
for _, w := range res.Workouts {
|
||||
if !bytes.Contains(w.Raw, []byte(`"name"`)) {
|
||||
t.Errorf("в содержимом тренировки %s нет ключа name", w.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Метрика, не объявленная категориальной, наблюдений не даёт, даже если у её
|
||||
// точек есть поле `value`: список объявлен явно, а не выведен из формы.
|
||||
func TestParseНеобъявленноеПолеНаблюденийНеДаёт(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := readTestdata(t, "handmade_edge.json")
|
||||
res, err := hae.Parse(body, hae.Meta{Locale: "ru", FallbackLayer: hae.LayerMinute})
|
||||
if err != nil {
|
||||
t.Fatalf("разбор: %v", err)
|
||||
}
|
||||
for _, c := range res.Categoricals {
|
||||
if c.Metric == "interval_points" {
|
||||
t.Errorf("метрика interval_points дала наблюдение %q, хотя категориальной не объявлена", c.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseФормыКатегориальногоЗначения(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
value string
|
||||
want string
|
||||
unknown int
|
||||
}{
|
||||
{"известная строка", `"Во сне"`, "Во сне", 0},
|
||||
{"незнакомая строка", `"Полудрёма"`, "Полудрёма", 1},
|
||||
{"число значением не считается", `123`, "", 0},
|
||||
{"null значением не считается", `null`, "", 0},
|
||||
{"объект значением не считается", `{"a":1}`, "", 0},
|
||||
{"массив значением не считается", `[1]`, "", 0},
|
||||
{"пустая строка значением не считается", `""`, "", 0},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := sleepBody(c.value)
|
||||
res, err := hae.Parse(body, hae.Meta{Locale: "ru", FallbackLayer: hae.LayerMinute})
|
||||
if err != nil {
|
||||
t.Fatalf("разбор: %v", err)
|
||||
}
|
||||
// Точка живёт при любой форме значения: категориальное поле не
|
||||
// заводит нового пути её потери.
|
||||
if len(res.Points) != 1 {
|
||||
t.Fatalf("точек %d, ожидалась одна (счётчики: malformed %d, no_time %d)",
|
||||
len(res.Points), res.SkippedMalformed, res.SkippedNoTime)
|
||||
}
|
||||
if !bytes.Contains(res.Points[0].Raw, []byte(c.value)) {
|
||||
t.Errorf("содержимое точки %s не несёт исходного значения %s", res.Points[0].Raw, c.value)
|
||||
}
|
||||
|
||||
var got string
|
||||
if len(res.Categoricals) == 1 {
|
||||
got = res.Categoricals[0].Value
|
||||
} else if len(res.Categoricals) > 1 {
|
||||
t.Fatalf("наблюдений %d, ожидалось не больше одного", len(res.Categoricals))
|
||||
}
|
||||
if got != c.want {
|
||||
t.Errorf("наблюдение %q, ожидалось %q", got, c.want)
|
||||
}
|
||||
if res.CategoricalUnknown != c.unknown {
|
||||
t.Errorf("строк без кода %d, ожидалось %d", res.CategoricalUnknown, c.unknown)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Локаль сужает поиск, но её отсутствие вывода не отменяет: заголовков в сыром
|
||||
// архиве нет, и усыновлённое тело обязано дать то же состояние.
|
||||
func TestParseЛокальНаКлючНаблюденияНеВлияет(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := readTestdata(t, "sparse_sleep.json")
|
||||
|
||||
withLocale, err := hae.Parse(body, hae.Meta{Locale: "ru", FallbackLayer: hae.LayerMinute})
|
||||
if err != nil {
|
||||
t.Fatalf("разбор с локалью: %v", err)
|
||||
}
|
||||
without, err := hae.Parse(body, hae.Meta{FallbackLayer: hae.LayerMinute})
|
||||
if err != nil {
|
||||
t.Fatalf("разбор без локали: %v", err)
|
||||
}
|
||||
|
||||
assertCategoricals(t, without.Categoricals, withLocale.Categoricals)
|
||||
}
|
||||
|
||||
// Уцелевший при переполнении набор — функция МНОЖЕСТВА, а не порядка элементов
|
||||
// на проводе: порядок ключей у HAE нестабилен, и то же содержимое,
|
||||
// переприсланное иначе, не имеет права дать другой реестр.
|
||||
func TestParseГраницаЧислаНаблюденийНеЗависитОтПорядка(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
values := make([]string, 0, 200)
|
||||
for i := range 200 {
|
||||
values = append(values, fmt.Sprintf("фаза-%03d", i))
|
||||
}
|
||||
forward := hae.Meta{Locale: "ru", FallbackLayer: hae.LayerMinute}
|
||||
|
||||
res, err := hae.Parse(sleepBodyMany(values), forward)
|
||||
if err != nil {
|
||||
t.Fatalf("разбор: %v", err)
|
||||
}
|
||||
if len(res.Categoricals) != 64 {
|
||||
t.Fatalf("наблюдений %d, ожидался предел 64", len(res.Categoricals))
|
||||
}
|
||||
if res.CategoricalDropped != len(values)-64 {
|
||||
t.Errorf("отброшено %d, ожидалось %d", res.CategoricalDropped, len(values)-64)
|
||||
}
|
||||
if len(res.Points) != len(values) {
|
||||
t.Errorf("точек %d, ожидалось %d: граница теряет наблюдение, а не данные", len(res.Points), len(values))
|
||||
}
|
||||
|
||||
reversed := make([]string, len(values))
|
||||
for i, v := range values {
|
||||
reversed[len(values)-1-i] = v
|
||||
}
|
||||
back, err := hae.Parse(sleepBodyMany(reversed), forward)
|
||||
if err != nil {
|
||||
t.Fatalf("разбор в обратном порядке: %v", err)
|
||||
}
|
||||
assertCategoricals(t, back.Categoricals, res.Categoricals)
|
||||
}
|
||||
|
||||
func TestParseНепомерноеЗначениеОтброшеноЦеликом(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
long := strings.Repeat("я", 200) // 400 байт UTF-8
|
||||
res, err := hae.Parse(sleepBody(`"`+long+`"`), hae.Meta{Locale: "ru", FallbackLayer: hae.LayerMinute})
|
||||
if err != nil {
|
||||
t.Fatalf("разбор: %v", err)
|
||||
}
|
||||
if len(res.Categoricals) != 0 {
|
||||
t.Errorf("наблюдений %d, ожидался ноль", len(res.Categoricals))
|
||||
}
|
||||
if res.CategoricalDropped != 1 {
|
||||
t.Errorf("отброшено %d, ожидалась единица", res.CategoricalDropped)
|
||||
}
|
||||
// Точка хранится целиком: теряется наблюдение, а не данные.
|
||||
if len(res.Points) != 1 || !bytes.Contains(res.Points[0].Raw, []byte(long)) {
|
||||
t.Error("непомерное значение не сохранилось в содержимом точки целиком")
|
||||
}
|
||||
}
|
||||
|
||||
// Доставка, у которой слой не выводится, наблюдений не отдаёт: «всё или ничего»
|
||||
// относится к доставке целиком, иначе реестр пополнился бы при незаписанной
|
||||
// витрине и повторная свёртка перестала бы быть no-op.
|
||||
func TestParseОтказПоСлоюНаблюденийНеОтдаёт(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Одна точка на середине часа: плотных метрик нет, наследовать нечего.
|
||||
body := []byte(`{"data":{"metrics":[{"name":"sleep_analysis","units":"hr","data":[
|
||||
{"date":"2026-07-30 21:48:17 +0300","value":"Во сне"}]}]}}`)
|
||||
res, err := hae.Parse(body, hae.Meta{Locale: "ru"})
|
||||
if err == nil {
|
||||
t.Skipf("слой выведен, отказа нет — проверять нечего (наблюдений %d)", len(res.Categoricals))
|
||||
}
|
||||
if len(res.Categoricals) != 0 {
|
||||
t.Errorf("при отказе разбора отдано %d наблюдений", len(res.Categoricals))
|
||||
}
|
||||
}
|
||||
|
||||
func sleepBody(value string) []byte {
|
||||
return []byte(`{"data":{"metrics":[{"name":"sleep_analysis","units":"hr","data":[
|
||||
{"date":"2026-07-30 21:00:00 +0300","start":"2026-07-30 21:00:00 +0300",` +
|
||||
`"end":"2026-07-30 22:00:00 +0300","qty":1,"value":` + value + `}]}]}}`)
|
||||
}
|
||||
|
||||
func sleepBodyMany(values []string) []byte {
|
||||
var b strings.Builder
|
||||
b.WriteString(`{"data":{"metrics":[{"name":"sleep_analysis","units":"hr","data":[`)
|
||||
for i, v := range values {
|
||||
if i > 0 {
|
||||
b.WriteByte(',')
|
||||
}
|
||||
// Метки ровно на часах: слой выводится, отказа не будет.
|
||||
fmt.Fprintf(&b, `{"date":"2026-07-30 %02d:00:00 +0300","qty":1,"value":%q}`, i%24, v)
|
||||
}
|
||||
b.WriteString(`]}]}}`)
|
||||
return []byte(b.String())
|
||||
}
|
||||
|
||||
func assertCategoricals(t *testing.T, got, want []hae.Categorical) {
|
||||
t.Helper()
|
||||
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("наблюдений %d, ожидалось %d:\n получено %v\n ожидалось %v", len(got), len(want), got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Errorf("наблюдение %d: %+v, ожидалось %+v", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertRawContains(t *testing.T, points []hae.Point, metric, want string) {
|
||||
t.Helper()
|
||||
|
||||
for _, p := range points {
|
||||
if p.Metric == metric && bytes.Contains(p.Raw, []byte(want)) {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Errorf("ни одна точка метрики %s не несёт %s дословно", metric, want)
|
||||
}
|
||||
|
||||
func readTestdata(t *testing.T, name string) []byte {
|
||||
t.Helper()
|
||||
|
||||
body, err := os.ReadFile(filepath.Join("testdata", name))
|
||||
if err != nil {
|
||||
t.Fatalf("чтение %s: %v", name, err)
|
||||
}
|
||||
// Файл обязан быть настоящим пакетом HAE, а не выдумкой: разбор формата
|
||||
// проверяется живыми данными.
|
||||
var env struct {
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &env); err != nil || len(env.Data) == 0 {
|
||||
t.Fatalf("%s не пакет HAE", name)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// Невалидный UTF-8 наблюдением не считается: encoding/json ошибки на нём не
|
||||
// даёт, он ЗАМЕНЯЕТ негодные байты на U+FFFD — строка на выходе уже не равна
|
||||
// пришедшей, а реестр требует хранить значение дословно. Точка при этом живёт.
|
||||
func TestParseНевалидныйUTF8НаблюденияНеДаёт(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Байт 0xFF внутри строкового литерала JSON: сам литерал валиден по
|
||||
// грамматике, а его содержимое — нет.
|
||||
body := []byte(`{"data":{"metrics":[{"name":"sleep_analysis","units":"hr","data":[` +
|
||||
"{\"date\":\"2026-07-30 21:00:00 +0300\",\"qty\":1,\"value\":\"\xffВо сне\"}" +
|
||||
`]}]}}`)
|
||||
|
||||
res, err := hae.Parse(body, hae.Meta{Locale: "ru", FallbackLayer: hae.LayerMinute})
|
||||
if err != nil {
|
||||
t.Fatalf("разбор: %v", err)
|
||||
}
|
||||
if len(res.Points) != 1 {
|
||||
t.Fatalf("точек %d, ожидалась одна: невалидный UTF-8 не имеет права уносить точку", len(res.Points))
|
||||
}
|
||||
if len(res.Categoricals) != 0 {
|
||||
t.Errorf("наблюдений %d, ожидался ноль: %+v", len(res.Categoricals), res.Categoricals)
|
||||
}
|
||||
if res.CategoricalUnknown != 0 {
|
||||
t.Errorf("счётчик строк без кода %d, ожидался ноль", res.CategoricalUnknown)
|
||||
}
|
||||
}
|
||||
|
||||
// Граница числа применяется при накоплении: удерживаются 64 НАИМЕНЬШИХ ключа, а
|
||||
// не первые попавшиеся. Проверяется на трёх перестановках, а не на паре.
|
||||
func TestParseУдерживаютсяНаименьшиеКлючи(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
values := make([]string, 0, 100)
|
||||
for i := range 100 {
|
||||
values = append(values, fmt.Sprintf("фаза-%03d", i))
|
||||
}
|
||||
|
||||
orders := map[string][]string{
|
||||
"по возрастанию": append([]string(nil), values...),
|
||||
"по убыванию": nil,
|
||||
"вперемешку": nil,
|
||||
}
|
||||
for i := len(values) - 1; i >= 0; i-- {
|
||||
orders["по убыванию"] = append(orders["по убыванию"], values[i])
|
||||
}
|
||||
// Детерминированная перестановка без источника случайности: тест обязан
|
||||
// краснеть воспроизводимо.
|
||||
for i := range values {
|
||||
orders["вперемешку"] = append(orders["вперемешку"], values[(i*37)%len(values)])
|
||||
}
|
||||
|
||||
want := values[:64]
|
||||
for name, order := range orders {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
res, err := hae.Parse(sleepBodyMany(order), hae.Meta{Locale: "ru", FallbackLayer: hae.LayerMinute})
|
||||
if err != nil {
|
||||
t.Fatalf("разбор: %v", err)
|
||||
}
|
||||
if len(res.Categoricals) != len(want) {
|
||||
t.Fatalf("наблюдений %d, ожидалось %d", len(res.Categoricals), len(want))
|
||||
}
|
||||
for i, c := range res.Categoricals {
|
||||
if c.Value != want[i] {
|
||||
t.Fatalf("наблюдение %d: %q, ожидалось %q — удержаны не наименьшие ключи", i, c.Value, want[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+66
-2
@@ -179,6 +179,32 @@ type Result struct {
|
||||
// по координате.
|
||||
SkippedBadEnd int
|
||||
|
||||
// Categoricals — различные наблюдённые категориальные значения доставки с
|
||||
// выведенными кодами, в детерминированном порядке. Повтор одной строки в
|
||||
// тысяче точек даёт один элемент.
|
||||
Categoricals []Categorical
|
||||
// CategoricalUnknown — сколько РАЗЛИЧНЫХ наблюдений не получило кода.
|
||||
// Считаются различные значения, а не их вхождения: счётчик отвечает на
|
||||
// вопрос «сколько строк ждёт словаря», а не «сколько точек их несло».
|
||||
//
|
||||
// В установившемся режиме он ненулевой — словарь покрывает только фазы сна,
|
||||
// а контекст пульса и имена тренировок объявлены категориальными заранее.
|
||||
// Сигналом «появилось новое» служит поэтому новая строка реестра, а не
|
||||
// ненулевой счётчик.
|
||||
CategoricalUnknown int
|
||||
// CategoricalDropped — сколько ВХОЖДЕНИЙ отброшено границами: непомерная
|
||||
// длина значения или переполнение числа различных значений.
|
||||
//
|
||||
// Единица названа вслух и отличается от соседнего счётчика намеренно.
|
||||
// Считать здесь различные значения нечем: набор ограничен при вставке (иначе
|
||||
// накопитель растёт вместе с телом, а тело контролирует отправитель), и
|
||||
// отброшенный ключ нигде не запоминается — запомнить его значило бы вернуть
|
||||
// ровно тот неограниченный рост, ради устранения которого граница и стоит на
|
||||
// вставке. Поэтому счётчик отвечает на вопрос «сколько раз сработала
|
||||
// граница», а не «сколько строк потеряно»; на второй отвечает реестр в
|
||||
// витрине.
|
||||
CategoricalDropped int
|
||||
|
||||
// Layer — слой, выведенный для доставки в целом (тот, что наследуют редкие
|
||||
// метрики). Пустой, если плотных метрик не было и наследовать было нечего.
|
||||
// Его сохраняет вызывающий, чтобы следующая доставка той же автоматизации
|
||||
@@ -204,6 +230,13 @@ type Meta struct {
|
||||
// из 89, обе с заголовком `Default`. Ищет и передаёт его вызывающий —
|
||||
// разбор остаётся чистой функцией.
|
||||
FallbackLayer Layer
|
||||
|
||||
// Locale — нормализованный языковой тег доставки из `Accept-Language`
|
||||
// (находка 32). Сужает поиск по словарю категориальных значений и НИКУДА не
|
||||
// сохраняется: заголовков в сыром архиве нет, поэтому доставка,
|
||||
// восстановленная из осиротевшего тела, приезжает без локали — и обязана
|
||||
// дать то же состояние. Пустая локаль законна.
|
||||
Locale string
|
||||
}
|
||||
|
||||
// Parse разбирает секцию metrics тела доставки в точки.
|
||||
@@ -231,18 +264,28 @@ func Parse(body []byte, meta Meta) (res Result, err error) {
|
||||
res.Uncovered = env.uncovered
|
||||
res.UncoveredDropped = env.dropped
|
||||
|
||||
cat := newCategoricals(meta.Locale)
|
||||
|
||||
res.Workouts = decodeEntities(env.workouts, workoutsSection, &res)
|
||||
res.Records = decodeEntities(env.stateOfMind, stateOfMindSection, &res)
|
||||
// Имя тренировки берётся из уже разобранного заголовка сущности. Мягкое
|
||||
// чтение превратило значение не того типа в пустую строку, а пустая строка
|
||||
// наблюдением не считается, — то есть «имени не было» и «имя приехало
|
||||
// числом» дают один исход, и он верный.
|
||||
for _, w := range res.Workouts {
|
||||
cat.add(workoutsSection, fieldName, w.Name)
|
||||
}
|
||||
|
||||
metrics := env.metrics
|
||||
res.Metrics = len(metrics)
|
||||
if len(metrics) == 0 {
|
||||
res.Categoricals, res.CategoricalUnknown, res.CategoricalDropped = cat.result()
|
||||
return res, nil
|
||||
}
|
||||
|
||||
groups := make([]group, 0, len(metrics))
|
||||
for _, m := range metrics {
|
||||
g := decodeGroup(m, &res)
|
||||
g := decodeGroup(m, &res, cat)
|
||||
if len(g.summaries) > 0 {
|
||||
groups = append(groups, group{
|
||||
metric: sleepSummaryMetric,
|
||||
@@ -258,6 +301,7 @@ func Parse(body []byte, meta Meta) (res Result, err error) {
|
||||
}
|
||||
}
|
||||
if len(groups) == 0 {
|
||||
res.Categoricals, res.CategoricalUnknown, res.CategoricalDropped = cat.result()
|
||||
return res, nil
|
||||
}
|
||||
|
||||
@@ -280,6 +324,11 @@ func Parse(body []byte, meta Meta) (res Result, err error) {
|
||||
}, err
|
||||
}
|
||||
|
||||
// Наблюдения отдаются только на успешном исходе: «всё или ничего» относится
|
||||
// к доставке целиком. У отказа по слою (см. ветку выше) сущности не
|
||||
// отдаются по той же причине.
|
||||
res.Categoricals, res.CategoricalUnknown, res.CategoricalDropped = cat.result()
|
||||
|
||||
total := 0
|
||||
for _, g := range groups {
|
||||
total += len(g.points)
|
||||
@@ -400,6 +449,15 @@ type pointHead struct {
|
||||
// TotalSleep различает две схемы под именем sleep_analysis: поэпизодную и
|
||||
// суточную сводку. Общих полей, кроме date и source, у них нет.
|
||||
TotalSleep *json.RawMessage `json:"totalSleep"`
|
||||
|
||||
// Value и Context — категориальные поля точки (фаза сна и контекст пульса,
|
||||
// находка 37). Сырыми сообщениями, а не строками: объяви их `string`, и
|
||||
// точка, у которой поле пришло числом, перестала бы разбираться вовсе —
|
||||
// json.Unmarshal отвечает ошибкой на несовпадение типа, а decodeGroup
|
||||
// считает такую точку не разобравшейся. Новый путь потери точки ради
|
||||
// удобства структуры недопустим.
|
||||
Value *json.RawMessage `json:"value"`
|
||||
Context *json.RawMessage `json:"context"`
|
||||
}
|
||||
|
||||
// decodeEnvelope разбирает конверт: отдаёт секцию metrics и имена секций,
|
||||
@@ -665,7 +723,7 @@ func clipSection(name string) string {
|
||||
// уже отфильтрован пропусками, и любой пропуск сдвигал бы соответствие — эпизод
|
||||
// сна уезжал бы под имя суточной сводки, а сводка под имя эпизода. Индексной
|
||||
// корреляции между двумя списками здесь не существует по построению.
|
||||
func decodeGroup(m metricEnvelope, res *Result) group {
|
||||
func decodeGroup(m metricEnvelope, res *Result, cat *categoricals) group {
|
||||
g := group{metric: m.Name, units: m.Units, points: make([]Point, 0, len(m.Data))}
|
||||
|
||||
for _, raw := range m.Data {
|
||||
@@ -716,8 +774,14 @@ func decodeGroup(m metricEnvelope, res *Result) group {
|
||||
if m.Name == sleepMetric && head.TotalSleep != nil {
|
||||
p.Metric = sleepSummaryMetric
|
||||
g.summaries = append(g.summaries, p)
|
||||
// Наблюдение снимается по ИТОГОВОМУ имени метрики, тому же, которым
|
||||
// адресуется единица хранения. У суточной сводки категориальных
|
||||
// полей нет, так что здесь это ноль работы, — но правило записано
|
||||
// один раз и не разойдётся при следующем разделении схем.
|
||||
cat.addPoint(p.Metric, &head)
|
||||
continue
|
||||
}
|
||||
cat.addPoint(p.Metric, &head)
|
||||
g.points = append(g.points, p)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user