package canon_test import ( "bytes" "encoding/json" "fmt" "testing" "git.vakhrushev.me/av/healthlog/internal/canon" ) // Пары взяты с живого потока (docs/research/apple-health.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 TestRelateFullness(t *testing.T) { t.Parallel() cases := []struct { name string a string b string want canon.Fullness }{ {"одинаковые", `{"qty":1}`, `{"qty":1}`, canon.FullnessEqual}, { "надмножество", `{"qty":1,"context":"x"}`, `{"qty":1}`, canon.FullnessSuperset, }, { "подмножество", `{"qty":1}`, `{"qty":1,"context":"x"}`, canon.FullnessSubset, }, { // Тот случай, ради которого правило и переписано: числом ключей он // не выражается вовсе. name: "несравнимые", a: `{"qty":1}`, b: `{"context":"x"}`, want: canon.FullnessIncomparable, }, { // source нестабилен и переписывается задним числом, поэтому его // наличие ничего не говорит о полноте измерения. name: "source не считается", a: `{"qty":1,"source":"Device A"}`, b: `{"qty":1}`, want: canon.FullnessEqual, }, { // Регрессия задачи: пять полей без содержания против настоящего // измерения. Счётчик давал 5 против 2 и стирал измерение. name: "поля без содержания не добавляют полноты", a: `{"qty":0,"a":0,"b":0,"c":{},"d":[]}`, b: `{"date":"2026-07-31 12:00:00 +0300","qty":123.4}`, want: canon.FullnessSubset, }, { // Второй разряд: содержательные ключи те же, но нулевые поля // теряться не должны. // Второй разряд работает именно при РАВНОМ содержании: qty один и // тот же, лишние ключи пусты — терять их незачем. name: "при равном содержании выигрывает набор со всеми ключами", a: `{"date":"d","qty":10,"Min":0,"Max":0}`, b: `{"date":"d","qty":10}`, want: canon.FullnessSuperset, }, { // А вот при РАЗНОМ содержании лишние пустые ключи полноты не дают: // точки несут разные измерения, и надмножество имён об этом ничего // не говорит. Иначе точка, где ни одно значение не измерение, // вытесняла бы настоящее измерение. name: "разное содержание не перебивается пустыми ключами", a: `{"date":"d","qty":10,"Min":0,"Max":0}`, b: `{"date":"d","qty":12}`, want: canon.FullnessEqual, }, { // Тот же дефект в самой опасной форме: падинг из null. Множества // содержательных ключей равны, поэтому раньше решал второй разряд — // и настоящее измерение проигрывало точке, не несущей измерения. name: "падинг из null не полнее измерения", a: `{"date":"d","qty":0.001,"p1":null,"p2":null,"p3":null}`, b: `{"date":"d","qty":72.5}`, want: canon.FullnessEqual, }, { // Надмножество содержательных ключей тоже обязано СОГЛАСОВЫВАТЬСЯ // по общим значениям, иначе `qty:false` побеждало бы `qty:72.5` // одним лишь наличием соседних полей. name: "надмножество с чужим значением полноты не даёт", a: `{"date":"d","qty":false,"Min":false,"Max":false}`, b: `{"date":"d","qty":72.5}`, want: canon.FullnessEqual, }, { // Настоящее надмножество: общее значение совпадает, поля добавлены. name: "надмножество с тем же значением полнее", a: `{"date":"d","qty":72.5,"Min":70,"Max":75}`, b: `{"date":"d","qty":72.5}`, want: canon.FullnessSuperset, }, { // false — одно из двух значений булева поля, а не отсутствие // сведений: `isIndoor: false` это тренировка на улице. name: "false содержателен", a: `{"qty":1,"isIndoor":false}`, b: `{"qty":1}`, want: canon.FullnessSuperset, }, { // Пустота считается по разобранному значению: будь она побайтовой, // эти поля прошли бы как содержательные и набор стал бы несравнимым. name: "запись нуля и пустоты роли не играет", a: `{"qty":1,"a":0.0,"b":-0,"c":0e0,"d":{ },"e":[ ],"f":""}`, b: `{"qty":1,"context":"x"}`, want: canon.FullnessSubset, }, { // Ключ без содержания всё же лучше его отсутствия — но только когда // содержательные множества уже сравнялись. name: "лишний пустой ключ решает вторым разрядом", a: `{"qty":1,"Min":0}`, b: `{"qty":1}`, want: canon.FullnessSuperset, }, { // Граница пустоты проведена по содержанию, а не по «похоже на // пустое»: пробел, строка "0" и контейнер с элементом — содержание. name: "похожее на пустоту содержательно", a: `{"qty":1,"a":" ","b":"0","c":[null],"d":{"x":null}}`, b: `{"qty":1}`, want: canon.FullnessSuperset, }, {"не объект против точки", `[1,2,3]`, `{"qty":1}`, canon.FullnessSubset}, {"оба не объекты", `[1,2,3]`, `"строка"`, canon.FullnessEqual}, {"невалидный JSON", `{"qty":`, `{"qty":1}`, canon.FullnessSubset}, {"пустой вход", ``, ``, canon.FullnessEqual}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { t.Parallel() got := canon.RelateFullness([]byte(c.a), []byte(c.b)) if got != c.want { t.Errorf("полнота %s против %s = %s, ожидалось %s", c.a, c.b, got, c.want) } // Отношение обязано быть симметричным: свёртка по журналу не знает, // какая из точек «первая». want := mirror(c.want) if got := canon.RelateFullness([]byte(c.b), []byte(c.a)); got != want { t.Errorf("обратный порядок = %s, ожидалось %s", got, want) } }) } } func mirror(f canon.Fullness) canon.Fullness { switch f { case canon.FullnessSuperset: return canon.FullnessSubset case canon.FullnessSubset: return canon.FullnessSuperset default: return f } } // Полнота смотрит на наличие содержания, а не на форму записи: значения, // различающиеся дребезгом последнего разряда, обязаны давать равные множества. func TestRelateFullnessУстойчивКФормеЗаписи(t *testing.T) { t.Parallel() a := []byte(`{"qty":0.09523182962471353,"date":"d"}`) b := []byte(` { "date" : "d" , "qty" : 0.09523182962471352 } `) if got := canon.RelateFullness(a, b); got != canon.FullnessEqual { t.Errorf("полнота = %s, ожидалось %s", got, canon.FullnessEqual) } if !canon.Equal(a, b) { t.Error("канонические формы разошлись — тест проверяет не то") } } // Нулевое значение типа не должно совпадать ни с одним исходом: забытое поле // или ранний возврат не выглядят как «множества равны». func TestFullnessНулевоеЗначениеНеИсход(t *testing.T) { t.Parallel() var zero canon.Fullness for _, f := range []canon.Fullness{ canon.FullnessEqual, canon.FullnessSuperset, canon.FullnessSubset, canon.FullnessIncomparable, } { if f == zero { t.Errorf("исход %s совпал с нулевым значением", f) } if f.String() == zero.String() { t.Errorf("имя исхода %s совпало с именем нулевого значения", f) } } } // Точка HAE несёт до нескольких десятков полей, но правило обязано быть // тотальным и на неправдоподобном входе: паника здесь остановила бы разбор // доставки целиком. func TestRelateFullnessНеПаникуетНаБольшомВходе(t *testing.T) { t.Parallel() var buf bytes.Buffer buf.WriteByte('{') for i := range 5000 { if i > 0 { buf.WriteByte(',') } fmt.Fprintf(&buf, `"k%d":%d`, i, i) } buf.WriteByte('}') if got := canon.RelateFullness(buf.Bytes(), []byte(`{"k1":1}`)); got != canon.FullnessSuperset { t.Errorf("полнота = %s, ожидалось %s", got, canon.FullnessSuperset) } if got := canon.RelateFullness(nil, nil); got != canon.FullnessEqual { t.Errorf("полнота на nil = %s, ожидалось %s", got, canon.FullnessEqual) } } // Тай-брейк обязан зависеть только от значений: свёртка по журналу должна // давать то же состояние, что приём в реальном времени, а внутри одной // доставки время приёма у столкнувшихся точек общее. 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) } }) } // covers — сахар для таблиц ниже: Covers работает на разобранных множествах. func covers(a, b string) bool { return canon.Analyze([]byte(a)).Covers(canon.Analyze([]byte(b))) } // Покрытие — отношение «не потеряем содержания», и проверяется оно по четырём // условиям сразу. Оракулы взяты из враждебного прохода ревью: тело, которым // отправитель управляет целиком, строится так, чтобы пройти проверку и вынести // маршрут — 95% содержимого тренировки, которого нет в экспорте Apple. func TestCoversЧетыреУсловия(t *testing.T) { t.Parallel() const ( // Настоящая тренировка (форма — из testdata/workout_indoor.json). real = `{"id":"w7","name":"В помещении Ходьба","isIndoor":true, "maxHeartRate":{"qty":199,"units":"count/min"}, "heartRate":{"max":{"qty":199},"avg":{"qty":47.2}}, "heartRateData":[{"Max":199,"Avg":86.1},{"Max":150,"Avg":80.0}], "activeEnergy":[{"qty":49.4},{"qty":12.1}], "totalEnergy":{"qty":66.4},"duration":11.1}` // «Скелет»: те же имена ключей, те же длины массивов, содержания нет. skeleton = `{"id":"w7","name":"x","isIndoor":false, "maxHeartRate":1,"heartRate":1, "heartRateData":[null,null],"activeEnergy":[null,null], "totalEnergy":1,"duration":1}` route3 = `{"id":"w9","route":[{"lat":1,"lon":10},{"lat":2},{"lat":3}]}` routeNull3 = `{"id":"w9","route":[null,null,null]}` routeEmpty = `{"id":"w9","route":[{},{},{}]}` routeShort = `{"id":"w9","route":[{"lat":1,"lon":10}]}` withEmpty = `{"id":"w9","qty":10,"context":null}` noEmpty = `{"id":"w9","qty":10}` richer = `{"id":"w9","qty":10,"context":null,"stepCount":900}` ) cases := []struct { name string a, b string want bool }{ {"скелет не покрывает настоящую", skeleton, real, false}, {"настоящая покрывает скелет", real, skeleton, true}, {"ряд из null не покрывает содержательный", routeNull3, route3, false}, {"ряд из пустых объектов не покрывает содержательный", routeEmpty, route3, false}, {"содержательный ряд покрывает пустой той же длины", route3, routeNull3, true}, {"усечённый ряд не покрывает полный", routeShort, route3, false}, {"ключ с пустым значением не исчезает", noEmpty, withEmpty, false}, {"версия с пустым ключом покрывает версию без него", withEmpty, noEmpty, true}, {"более полная покрывает", richer, withEmpty, true}, {"менее полная не покрывает", withEmpty, richer, false}, {"версия покрывает саму себя", real, real, true}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { if got := covers(c.a, c.b); got != c.want { t.Errorf("Covers = %v, ожидалось %v", got, c.want) } }) } } // Запрет вырождения формы: покрывающая версия не может подменить объект или // массив скаляром. Обратное разрешено — объект вместо скаляра богаче формой. func TestCoversЗапретВырожденияФормы(t *testing.T) { t.Parallel() cases := []struct { name string a, b string want bool }{ {"скаляр не покрывает объект", `{"hr":1}`, `{"hr":{"qty":199}}`, false}, {"скаляр не покрывает массив", `{"hr":1}`, `{"hr":[{"qty":199}]}`, false}, {"объект не покрывает массив", `{"hr":{"qty":1}}`, `{"hr":[{"qty":1}]}`, false}, {"массив не покрывает объект", `{"hr":[{"qty":1}]}`, `{"hr":{"qty":1}}`, false}, {"объект покрывает скаляр", `{"hr":{"qty":1}}`, `{"hr":1}`, true}, {"строка покрывает число", `{"hr":"x"}`, `{"hr":1}`, true}, {"пустой ключ формы не требует", `{"hr":0,"id":"a"}`, `{"hr":[],"id":"a"}`, true}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { if got := covers(c.a, c.b); got != c.want { t.Errorf("Covers = %v, ожидалось %v", got, c.want) } }) } } // Покрытие — частичный порядок, и на транзитивности стоит выбор победителя из // МНОЖЕСТВА версий: без неё «непревзойдённые» определены неоднозначно, и // победитель становится функцией порядка элементов на проводе. func TestCoversТранзитивно(t *testing.T) { t.Parallel() versions := []string{ `{"id":"w","a":1}`, `{"id":"w","a":1,"b":null}`, `{"id":"w","a":1,"b":2}`, `{"id":"w","a":1,"b":2,"c":[1,2]}`, `{"id":"w","a":1,"b":2,"c":[1,2,3]}`, `{"id":"w","a":1,"b":2,"c":[null,null,null]}`, `{"id":"w","a":1,"c":3}`, `{"id":"w"}`, } for i, x := range versions { for j, y := range versions { if !covers(x, y) { continue } for k, z := range versions { if !covers(y, z) { continue } if !covers(x, z) { t.Errorf("нетранзитивно: %d ⊇ %d ⊇ %d, но %d не покрывает %d", i, j, k, i, k) } } } } } // Форма и хеш обязаны быть одной функцией: сравнение по одной канонизации и // хеширование по другой разошлись бы молча, а хеш-детектор превратился бы в // перезапись недели каждым глубоким проходом. func TestFormAndHashСовпадаетСОтдельнымиВызовами(t *testing.T) { t.Parallel() raws := []string{ `{"qty":1.50,"date":"2025-06-05 07:00:00 +0300"}`, `{"b":[1,2,{"z":null}],"a":"строка"}`, `[1,2,3]`, `null`, } for _, raw := range raws { form, hash, err := canon.FormAndHash([]byte(raw)) if err != nil { t.Fatalf("FormAndHash(%s): %v", raw, err) } wantForm, err := canon.Form([]byte(raw)) if err != nil { t.Fatalf("Form(%s): %v", raw, err) } wantHash, err := canon.Hash([]byte(raw)) if err != nil { t.Fatalf("Hash(%s): %v", raw, err) } if !bytes.Equal(form, wantForm) { t.Errorf("форма разошлась: %s против %s", form, wantForm) } if hash != wantHash { t.Errorf("хеш разошёлся: %s против %s", hash, wantHash) } } if _, _, err := canon.FormAndHash([]byte(`{"qty":`)); err == nil { t.Error("усечённый JSON принят за корректный") } }