- фазы сна, контекст пульса и имена тренировок попадают в реестр `category_value` (миграция 00010): строка хранится дословно, выведенный код лежит рядом отдельной записью, а не полем внутри точки - словарь и синонимы кодов живут в бинаре (`internal/healthkit`); локаль из `Accept-Language` сужает поиск, но в ключ реестра не входит — заголовков в сыром архиве нет - наблюдение входит в отпечаток витрины, выведенный код — нет: он производная от словаря, а не от журнала
405 lines
17 KiB
Go
405 lines
17 KiB
Go
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])
|
||
}
|
||
}
|
||
})
|
||
}
|
||
}
|