Files
healthlog/internal/canon/canon_test.go
T
av 7a7594e3e7 полнота точки — множество ключей, победитель — функция множества точек
- отношение победы было нетранзитивным: полнота (частичный порядок) плюс
  тай-брейк (тотальный) в попарной свёртке давали цикл, из-за которого одна
  и та же доставка меняла содержимое объекта при каждой пересборке
- надмножество побеждает только при совпадении значений общих содержательных
  ключей: иначе точка без единого измерения вытесняла измерение
- Less стал тотальным, isEmpty не материализует значение, имя метрики в
  координате столкновения обрезается, отпечаток витрины включает units и sealed
- на живом архиве строгий no-op: 1737 объектов, содержимое совпало побайтово
2026-08-01 21:05:43 +03:00

450 lines
18 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 (
"bytes"
"encoding/json"
"fmt"
"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 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)
}
})
}