Дозакрыты находки ревью по слиянию сущностей
- Правило покрытия получило второй разряд (условный, как у точек), запрет вырождения формы и счёт содержательных элементов ряда: скелет из скаляров и ряд из null больше не затирают маршрут. Победитель внутри доставки стал функцией множества версий — общим помощником с точками, — а провенанс поднимается и при совпавшем хеше, иначе отложенная доставка возвращала витрину к прежнему содержимому. - Одно поле не того типа больше не уносит сущность, а пропуски видны в учётной записи доставки (миграция 00008, NULL = «не измерялось»); каноническая форма считается один раз и вне транзакции; откат бинаря поверх новой схемы отказывает на старте; текст ошибки разбора не несёт значений из тела. - Ревью кода профилем deep (девять проходов) нашло две регрессии и обе закрыты: безусловный второй разряд запирал законный досчёт навсегда, а выбор победителя был квадратичен по числу присланных версий одного ключа.
This commit is contained in:
+109
-7
@@ -149,14 +149,14 @@ func TestParseКраевыеСлучаиСущностей(t *testing.T) {
|
||||
byID[w.ID] = w
|
||||
}
|
||||
|
||||
// Пустой id, отсутствующий id и id длиннее предела — один счётчик на три
|
||||
// случая: исход у них общий.
|
||||
if res.SkippedNoID != 3 {
|
||||
t.Errorf("пропущено по идентификатору %d, ожидалось 3", res.SkippedNoID)
|
||||
// Пустой id, отсутствующий id, id длиннее предела и id не строкой — один
|
||||
// счётчик на четыре случая: исход у них общий, сущность не адресуема.
|
||||
if res.SkippedNoID != 4 {
|
||||
t.Errorf("пропущено по идентификатору %d, ожидалось 4", res.SkippedNoID)
|
||||
}
|
||||
// Метка не разбирается и метки нет вовсе.
|
||||
if res.SkippedEntityNoTime != 2 {
|
||||
t.Errorf("пропущено по метке %d, ожидалось 2", res.SkippedEntityNoTime)
|
||||
// Метка не разбирается, метки нет вовсе и начало приехало не строкой.
|
||||
if res.SkippedEntityNoTime != 3 {
|
||||
t.Errorf("пропущено по метке %d, ожидалось 3", res.SkippedEntityNoTime)
|
||||
}
|
||||
// Элемент, не являющийся объектом.
|
||||
if res.SkippedEntityMalformed != 1 {
|
||||
@@ -176,6 +176,50 @@ func TestParseКраевыеСлучаиСущностей(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
// Значение не того ТИПА стоит одного поля, а не сущности: иначе `name`,
|
||||
// приехавшее числом, уносит тренировку вместе с маршрутом, а доставка при
|
||||
// этом числится разобранной.
|
||||
t.Run("имя числом не уносит тренировку", func(t *testing.T) {
|
||||
w, ok := byID["00000000-0000-4000-8000-00000000000c"]
|
||||
if !ok {
|
||||
t.Fatal("тренировка с именем-числом потерялась целиком")
|
||||
}
|
||||
if w.Name != "" {
|
||||
t.Errorf("имя %q, ожидалось пустое", w.Name)
|
||||
}
|
||||
if !strings.Contains(string(w.Raw), `"lat"`) {
|
||||
t.Error("маршрут не сохранился дословно")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("конец числом не уносит тренировку", func(t *testing.T) {
|
||||
w, ok := byID["00000000-0000-4000-8000-00000000000d"]
|
||||
if !ok {
|
||||
t.Fatal("тренировка с концом-числом потерялась целиком")
|
||||
}
|
||||
if !w.End.Equal(w.Start) {
|
||||
t.Errorf("конец %v, ожидался равным началу %v", w.End, w.Start)
|
||||
}
|
||||
})
|
||||
|
||||
// Фолбэк `start → date` существует для сущностей, у которых `start` не
|
||||
// прислан ВОВСЕ. Непонятое значение `start` фолбэка не получает: подстановка
|
||||
// другого поля дала бы метку другого момента времени, неотличимую от
|
||||
// настоящей и ничем не считаемую.
|
||||
t.Run("начало числом не подменяется полем date", func(t *testing.T) {
|
||||
if _, ok := byID["00000000-0000-4000-8000-00000000000e"]; ok {
|
||||
t.Error("нестроковое начало молча заменено меткой из date")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("идентификатор числом пропускает сущность", func(t *testing.T) {
|
||||
for id := range byID {
|
||||
if id == "42" {
|
||||
t.Error("нестроковый идентификатор приведён к строке — идентичность выдумана за источник")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("нечисловая длительность не становится нулём", func(t *testing.T) {
|
||||
w := byID["00000000-0000-4000-8000-000000000004"]
|
||||
if w.Duration != nil {
|
||||
@@ -316,3 +360,61 @@ func TestParseНепокрытыеСекцииССобственнымиID(t *te
|
||||
t.Errorf("непокрытые %v", res.Uncovered)
|
||||
}
|
||||
}
|
||||
|
||||
// Элемент, который сам не объект, обязан идти в СВОЙ счётчик: сменившаяся форма
|
||||
// секции и сменившаяся форма идентификатора лечатся по-разному. `null` при этом
|
||||
// самый коварный — `json.Unmarshal` считает его законным no-op и не ошибается.
|
||||
func TestParseЭлементНеОбъектИдётВСвойСчётчик(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
res, err := hae.Parse([]byte(`{"data":{"workouts":[null,"строка",42,[1,2]]}}`), hae.Meta{})
|
||||
if err != nil {
|
||||
t.Fatalf("разбор: %v", err)
|
||||
}
|
||||
if res.SkippedEntityMalformed != 4 {
|
||||
t.Errorf("не разобралось как объект %d, ожидалось 4", res.SkippedEntityMalformed)
|
||||
}
|
||||
if res.SkippedNoID != 0 {
|
||||
t.Errorf("пропущено по идентификатору %d, ожидалось 0: форма секции — не форма id",
|
||||
res.SkippedNoID)
|
||||
}
|
||||
}
|
||||
|
||||
// Повтор ключа JSON допускает, и весь разбор проекта пользуется семантикой
|
||||
// «побеждает последнее». Мягкое чтение обязано ей следовать: признак, копящийся
|
||||
// между вызовами, сделал бы заголовок функцией истории вызовов, а не тела.
|
||||
func TestParseПовторКлючаМеткиРешаетсяПоследнимЗначением(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := `{"data":{"workouts":[{"id":"w1","start":123,"start":"2025-06-05 07:00:00 +0300"}]}}`
|
||||
res, err := hae.Parse([]byte(body), hae.Meta{})
|
||||
if err != nil {
|
||||
t.Fatalf("разбор: %v", err)
|
||||
}
|
||||
if len(res.Workouts) != 1 {
|
||||
t.Fatalf("тренировок %d, ожидалась 1: валидная метка стоит последней", len(res.Workouts))
|
||||
}
|
||||
if res.Workouts[0].Start.IsZero() {
|
||||
t.Error("метка не разобралась")
|
||||
}
|
||||
}
|
||||
|
||||
// `"start": null` — это ключ, который пришёл. Фолбэк на `date` для него не
|
||||
// срабатывает: подстановка дала бы метку ДРУГОГО момента времени, неотличимую
|
||||
// от настоящей и ничем не считаемую.
|
||||
func TestParseПустойStartНеПодменяетсяПолемDate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := `{"data":{"workouts":[{"id":"w1","date":"2025-06-01 00:00:00 +0300",` +
|
||||
`"start":null,"end":"2025-06-05 07:10:00 +0300"}]}}`
|
||||
res, err := hae.Parse([]byte(body), hae.Meta{})
|
||||
if err != nil {
|
||||
t.Fatalf("разбор: %v", err)
|
||||
}
|
||||
if len(res.Workouts) != 0 {
|
||||
t.Errorf("тренировка получила метку %v из чужого поля", res.Workouts[0].Start)
|
||||
}
|
||||
if res.SkippedEntityNoTime != 1 {
|
||||
t.Errorf("пропущено по метке %d, ожидалось 1", res.SkippedEntityNoTime)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user