Дозакрыты находки ревью по слиянию сущностей

- Правило покрытия получило второй разряд (условный, как у точек), запрет
  вырождения формы и счёт содержательных элементов ряда: скелет из скаляров и
  ряд из null больше не затирают маршрут. Победитель внутри доставки стал
  функцией множества версий — общим помощником с точками, — а провенанс
  поднимается и при совпавшем хеше, иначе отложенная доставка возвращала витрину
  к прежнему содержимому.
- Одно поле не того типа больше не уносит сущность, а пропуски видны в учётной
  записи доставки (миграция 00008, NULL = «не измерялось»); каноническая форма
  считается один раз и вне транзакции; откат бинаря поверх новой схемы отказывает
  на старте; текст ошибки разбора не несёт значений из тела.
- Ревью кода профилем deep (девять проходов) нашло две регрессии и обе закрыты:
  безусловный второй разряд запирал законный досчёт навсегда, а выбор победителя
  был квадратичен по числу присланных версий одного ключа.
This commit is contained in:
av
2026-08-02 16:38:18 +03:00
parent 51a5272c96
commit 8331328134
52 changed files with 4921 additions and 481 deletions
+158
View File
@@ -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 принят за корректный")
}
}