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]) } } }) } }