Дозакрыты находки ревью по слиянию сущностей
- Правило покрытия получило второй разряд (условный, как у точек), запрет вырождения формы и счёт содержательных элементов ряда: скелет из скаляров и ряд из null больше не затирают маршрут. Победитель внутри доставки стал функцией множества версий — общим помощником с точками, — а провенанс поднимается и при совпавшем хеше, иначе отложенная доставка возвращала витрину к прежнему содержимому. - Одно поле не того типа больше не уносит сущность, а пропуски видны в учётной записи доставки (миграция 00008, NULL = «не измерялось»); каноническая форма считается один раз и вне транзакции; откат бинаря поверх новой схемы отказывает на старте; текст ошибки разбора не несёт значений из тела. - Ревью кода профилем deep (девять проходов) нашло две регрессии и обе закрыты: безусловный второй разряд запирал законный досчёт навсегда, а выбор победителя был квадратичен по числу присланных версий одного ключа.
This commit is contained in:
@@ -447,3 +447,161 @@ func FuzzForm(f *testing.F) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 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 принят за корректный")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user