разбор метрик HAE: фикстуры, канонизация, парсер
- tmp/research/fixtures.py собирает фикстуры из архива, вычищая измерения и сохраняя порядок ключей, форму литералов, выравнивание меток и невидимые символы; шесть фикстур в internal/hae/testdata - internal/canon — общий дом канонической формы, полноты и хеша: числа читаются литералом через json.Number, округление до 12 значащих цифр - internal/hae — разбор секции metrics, вывод слоя по метрике, разделение схем сна, координаты интервалом; recover внутри Parse, фаззинг - миграция bucket и docs/database.md
This commit is contained in:
@@ -0,0 +1,275 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user