Files
healthlog/internal/hae/categorical_test.go
T
av 1b649ba3d5 добавлен словарь категориальных значений HAE → коды HealthKit
- фазы сна, контекст пульса и имена тренировок попадают в реестр
  `category_value` (миграция 00010): строка хранится дословно, выведенный код
  лежит рядом отдельной записью, а не полем внутри точки
- словарь и синонимы кодов живут в бинаре (`internal/healthkit`); локаль из
  `Accept-Language` сужает поиск, но в ключ реестра не входит — заголовков в
  сыром архиве нет
- наблюдение входит в отпечаток витрины, выведенный код — нет: он производная
  от словаря, а не от журнала
2026-08-04 07:32:19 +03:00

405 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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])
}
}
})
}
}