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