Files
healthlog/internal/canon/canon_test.go
T
av 01d0de59df разбор метрик HAE: фикстуры, канонизация, парсер
- tmp/research/fixtures.py собирает фикстуры из архива, вычищая измерения и
  сохраняя порядок ключей, форму литералов, выравнивание меток и невидимые
  символы; шесть фикстур в internal/hae/testdata
- internal/canon — общий дом канонической формы, полноты и хеша: числа читаются
  литералом через json.Number, округление до 12 значащих цифр
- internal/hae — разбор секции metrics, вывод слоя по метрике, разделение схем
  сна, координаты интервалом; recover внутри Parse, фаззинг
- миграция bucket и docs/database.md
2026-08-01 17:31:41 +03:00

276 lines
9.6 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 canon_test
import (
"encoding/json"
"testing"
"git.vakhrushev.me/av/healthlog/internal/canon"
)
// Пары взяты с живого потока (docs/local-research.md, находка 30): те же
// измерения в двух выгрузках, разошедшиеся последним разрядом double. Без
// округления 63% повторов считались бы новыми точками.
func TestFormСхлопываетДребезгПоследнегоРазряда(t *testing.T) {
t.Parallel()
pairs := []struct {
name string
a, b string
}{
{
name: "basal_energy_burned",
a: `{"qty":0.09523182962471353}`,
b: `{"qty":0.09523182962471352}`,
},
{
name: "active_energy",
a: `{"qty":0.0074754192155406605}`,
b: `{"qty":0.00747541921554066}`,
},
{
// Тот же случай, вынесенный за пределы двенадцатой значащей цифры
// явно: граница округления должна работать и на круглых числах.
name: "граница округления",
a: `{"qty":123.4567890123456}`,
b: `{"qty":123.4567890123499}`,
},
}
for _, p := range pairs {
t.Run(p.name, func(t *testing.T) {
t.Parallel()
ha, err := canon.Hash([]byte(p.a))
if err != nil {
t.Fatalf("хеш a: %v", err)
}
hb, err := canon.Hash([]byte(p.b))
if err != nil {
t.Fatalf("хеш b: %v", err)
}
if ha != hb {
fa, _ := canon.Form([]byte(p.a))
fb, _ := canon.Form([]byte(p.b))
t.Errorf("дребезг не схлопнулся:\n %s\n %s", fa, fb)
}
})
}
}
// Обратная сторона округления: настоящее различие обязано выжить. Без этого
// теста округление можно было бы «улучшить» до полной бесполезности.
func TestFormСохраняетНастоящееРазличие(t *testing.T) {
t.Parallel()
a := []byte(`{"qty":0.09523182962}`)
b := []byte(`{"qty":0.09523182963}`)
ha, err := canon.Hash(a)
if err != nil {
t.Fatalf("хеш a: %v", err)
}
hb, err := canon.Hash(b)
if err != nil {
t.Fatalf("хеш b: %v", err)
}
if ha == hb {
t.Error("различие в одиннадцатой значащей цифре съедено округлением")
}
}
// Порядок ключей в JSON от HAE нестабилен (находка 2): та же точка приезжает с
// разной раскладкой. Идентичность не имеет права от этого зависеть.
func TestFormНеЗависитОтПорядкаКлючей(t *testing.T) {
t.Parallel()
a := []byte(`{"date":"2025-06-05 10:00:00 +0300","qty":1.5,"source":"Device A"}`)
b := []byte(`{"source":"Device A","qty":1.5,"date":"2025-06-05 10:00:00 +0300"}`)
ha, err := canon.Hash(a)
if err != nil {
t.Fatalf("хеш a: %v", err)
}
hb, err := canon.Hash(b)
if err != nil {
t.Fatalf("хеш b: %v", err)
}
if ha != hb {
t.Error("перестановка ключей изменила идентичность точки")
}
}
// Канонизация не имеет права быть путём, по которому значение попадает в
// хранилище: она читает байты и ничего не отдаёт обратно. Тест фиксирует
// именно это — литералы, которые не переживают round-trip через разобранные
// значения, обрабатываются без паники и дают устойчивый хеш, а сами байты
// остаются нетронутыми у вызывающего.
func TestHashНеПортитИсходныеБайты(t *testing.T) {
t.Parallel()
// `1.0` теряет ноль, целое больше 2^53 сдвигается, невалидный UTF-8
// заменяется на U+FFFD — всё это происходит с КОПИЕЙ внутри canon.
raw := []byte("{\"exact\":1.0,\"huge\":9007199254740993,\"broken\":\"a\xff\xfeb\"}")
before := string(raw)
h1, err := canon.Hash(raw)
if err != nil {
t.Fatalf("хеш: %v", err)
}
h2, err := canon.Hash(raw)
if err != nil {
t.Fatalf("повторный хеш: %v", err)
}
if string(raw) != before {
t.Errorf("исходные байты изменены:\n было %q\n стало %q", before, raw)
}
if h1 != h2 {
t.Error("хеш одного и того же значения не воспроизводится")
}
}
// Целое больше 2^53 не должно проходить через float64 даже внутри
// канонизации: округление сдвинуло бы его, и две разные точки стали бы одной.
func TestFormНеСдвигаетБольшиеЦелые(t *testing.T) {
t.Parallel()
a := []byte(`{"id":9007199254740993}`)
b := []byte(`{"id":9007199254740992}`)
ha, err := canon.Hash(a)
if err != nil {
t.Fatalf("хеш a: %v", err)
}
hb, err := canon.Hash(b)
if err != nil {
t.Fatalf("хеш b: %v", err)
}
if ha == hb {
t.Error("соседние целые за пределами точности double схлопнулись")
}
form, err := canon.Form(a)
if err != nil {
t.Fatalf("форма: %v", err)
}
if want := `{"id":9007199254740993}`; string(form) != want {
t.Errorf("целое переписано:\n получено %s\n ожидалось %s", form, want)
}
}
func TestCompleteness(t *testing.T) {
t.Parallel()
cases := []struct {
name string
raw string
want int
}{
{"пустой объект", `{}`, 0},
{"только qty", `{"qty":1}`, 1},
{"qty и границы", `{"qty":1,"start":"a","end":"b"}`, 3},
{
// source нестабилен и переписывается задним числом, поэтому его
// наличие ничего не говорит о полноте измерения.
name: "source не считается",
raw: `{"qty":1,"source":"Device A"}`,
want: 1,
},
{
// Точка с пустым полем не полнее точки без него — иначе бедная
// доставка выиграла бы столкновение одним лишь наличием ключа.
name: "пустые значения не считаются",
raw: `{"qty":1,"context":null,"note":""}`,
want: 1,
},
{"не объект", `[1,2,3]`, 0},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
if got := canon.Completeness([]byte(c.raw)); got != c.want {
t.Errorf("полнота %s = %d, ожидалось %d", c.raw, got, c.want)
}
})
}
}
// Тай-брейк обязан зависеть только от значений: свёртка по журналу должна
// давать то же состояние, что приём в реальном времени, а внутри одной
// доставки время приёма у столкнувшихся точек общее.
func TestLessДетерминирован(t *testing.T) {
t.Parallel()
a := []byte(`{"qty":1,"value":"Во сне"}`)
b := []byte(`{"value":"В кровати","qty":2}`)
ab := canon.Less(a, b)
ba := canon.Less(b, a)
if ab == ba {
t.Fatal("порядок не строгий: обе точки считаются меньшими")
}
for range 10 {
if canon.Less(a, b) != ab {
t.Fatal("порядок не воспроизводится между вызовами")
}
}
}
// Каноническая форма — вход хеша, поэтому она обязана оставаться разбираемым
// JSON: иначе отладка столкновения сведётся к чтению байтов глазами.
func TestFormОстаётсяJSON(t *testing.T) {
t.Parallel()
raw := []byte(`{"b":[1,2.50,{"z":null,"a":true}],"a":"строка"}`)
form, err := canon.Form(raw)
if err != nil {
t.Fatalf("форма: %v", err)
}
var v any
if err := json.Unmarshal(form, &v); err != nil {
t.Fatalf("каноническая форма не разбирается: %v\n%s", err, form)
}
if want := `{"a":"строка","b":[1,2.5,{"a":true,"z":null}]}`; string(form) != want {
t.Errorf("форма:\n получено %s\n ожидалось %s", form, want)
}
}
func TestFormОшибкаНаНеJSON(t *testing.T) {
t.Parallel()
if _, err := canon.Form([]byte(`{"qty":`)); err == nil {
t.Error("усечённый JSON принят за корректный")
}
}
func FuzzForm(f *testing.F) {
f.Add(`{"qty":1.5}`)
f.Add(`{"a":{"b":[1,2,3]}}`)
f.Add(`[]`)
f.Add(`null`)
f.Add(`1e400`)
f.Add("{\"s\":\"\xff\"}")
f.Fuzz(func(t *testing.T, raw string) {
// Единственное требование: разбор произвольного входа не роняет
// процесс. Разбор чужого формата обязан отвечать ошибкой, а не паникой.
form, err := canon.Form([]byte(raw))
if err != nil {
return
}
// Каноническая форма корректного входа обязана быть устойчивой:
// канонизация канонической формы даёт её же.
again, err := canon.Form(form)
if err != nil {
t.Fatalf("каноническая форма не канонизируется повторно: %v\n%s", err, form)
}
if string(again) != string(form) {
t.Fatalf("канонизация не идемпотентна:\n %s\n %s", form, again)
}
})
}