feat: разбор и хранение тренировок и состояния разума
- секции `workouts` и `stateOfMind` покрыты разбором: тренировка лежит одной строкой вместе с маршрутом и внутренними рядами, запись — по ключу `род + id`; миграция 00007 заводит обе таблицы и возвращает в очередь `partial`-доставки с этими ключами - сущность заменяется целиком, но условно: приехавшая побеждает, если не теряет содержания сохранённой (множество ключей и длины верхнеуровневых массивов), а при равном содержании выигрывает версия из более поздней доставки ЖУРНАЛА — «побеждает приехавшая» было бы функцией порядка свёртки, и живая витрина расходилась бы с пересборкой молча - отпечаток витрины покрывает тренировки и записи и снимается одним снимком базы; отчёт `reindex` считает «было и стало» по каждой единице хранения
This commit is contained in:
@@ -0,0 +1,460 @@
|
||||
package store_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"git.vakhrushev.me/av/healthlog/internal/store"
|
||||
)
|
||||
|
||||
// workout собирает тренировку с заданным содержимым. Заголовок в этих тестах
|
||||
// вторичен: правило замены смотрит на содержание, а не на колонки.
|
||||
func workout(t *testing.T, id, raw string) store.IncomingEntity {
|
||||
t.Helper()
|
||||
|
||||
return store.IncomingEntity{
|
||||
ID: id,
|
||||
Kind: "workouts",
|
||||
Name: "На улице Ходьба",
|
||||
Start: ts(t, "2025-06-05T07:00:00Z"),
|
||||
End: ts(t, "2025-06-05T07:10:00Z"),
|
||||
OffsetSeconds: 3 * 3600,
|
||||
Raw: json.RawMessage(raw),
|
||||
}
|
||||
}
|
||||
|
||||
func from(t *testing.T, id, receivedAt string) store.DeliveryRef {
|
||||
t.Helper()
|
||||
|
||||
return store.DeliveryRef{ID: id, ReceivedAt: ts(t, receivedAt)}
|
||||
}
|
||||
|
||||
func mergeWorkouts(t *testing.T, st *store.Store, d store.DeliveryRef, ws ...store.IncomingEntity) store.MergeStats {
|
||||
t.Helper()
|
||||
|
||||
stats, err := st.Merge(context.Background(), store.Incoming{Workouts: ws}, d)
|
||||
if err != nil {
|
||||
t.Fatalf("слияние сущностей: %v", err)
|
||||
}
|
||||
return stats
|
||||
}
|
||||
|
||||
const (
|
||||
// Содержимое подобрано так, чтобы отличаться от прежнего ЗНАЧЕНИЯМИ общих
|
||||
// полей: именно так тренировка и меняется между версиями (досчёт энергии),
|
||||
// и именно на этом ломается правило полноты, написанное для точек.
|
||||
woWithRoute = `{"id":"w1","name":"На улице Ходьба","route":[{"lat":1},{"lat":2},{"lat":3}],
|
||||
"activeEnergy":[{"qty":10}],"totalEnergy":{"qty":20,"units":"kJ"}}`
|
||||
woNoRouteNewValues = `{"id":"w1","name":"На улице Ходьба",
|
||||
"activeEnergy":[{"qty":11}],"totalEnergy":{"qty":21,"units":"kJ"}}`
|
||||
woShortRoute = `{"id":"w1","name":"На улице Ходьба","route":[{"lat":1}],
|
||||
"activeEnergy":[{"qty":11}],"totalEnergy":{"qty":21,"units":"kJ"}}`
|
||||
woRicher = `{"id":"w1","name":"На улице Ходьба","route":[{"lat":1},{"lat":2},{"lat":3}],
|
||||
"activeEnergy":[{"qty":11}],"totalEnergy":{"qty":21,"units":"kJ"},"stepCount":{"qty":900}}`
|
||||
woSameShapeNewValues = `{"id":"w1","name":"На улице Ходьба","route":[{"lat":1},{"lat":2},{"lat":3}],
|
||||
"activeEnergy":[{"qty":11}],"totalEnergy":{"qty":21,"units":"kJ"}}`
|
||||
woIncomparable = `{"id":"w1","name":"На улице Ходьба",
|
||||
"activeEnergy":[{"qty":11}],"totalEnergy":{"qty":21,"units":"kJ"},"flightsClimbed":{"qty":3}}`
|
||||
)
|
||||
|
||||
func storedRaw(t *testing.T, st *store.Store, id string) string {
|
||||
t.Helper()
|
||||
|
||||
w, err := st.Workout(context.Background(), id)
|
||||
if err != nil {
|
||||
t.Fatalf("чтение тренировки: %v", err)
|
||||
}
|
||||
return string(w.Raw)
|
||||
}
|
||||
|
||||
// Единственная причина повторной присылки тренировки — доезжающий маршрут.
|
||||
func TestMergeДоехавшийМаршрутЗамещаетТренировку(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
mergeWorkouts(t, st, from(t, "d1", "2025-06-05T08:00:00Z"), workout(t, "w1", woNoRouteNewValues))
|
||||
stats := mergeWorkouts(t, st, from(t, "d2", "2025-06-05T08:05:00Z"), workout(t, "w1", woWithRoute))
|
||||
|
||||
if stats.WorkoutsWritten != 1 {
|
||||
t.Errorf("записано %d, ожидалась 1: версия с маршрутом полнее", stats.WorkoutsWritten)
|
||||
}
|
||||
if stats.EntitiesHeld != 0 {
|
||||
t.Errorf("удержано %d, ожидалось 0", stats.EntitiesHeld)
|
||||
}
|
||||
if got := storedRaw(t, st, "w1"); got != woWithRoute {
|
||||
t.Error("в витрине не версия с маршрутом")
|
||||
}
|
||||
}
|
||||
|
||||
// Тренировка досчитывается задним числом ровно так же, как минутное ведро:
|
||||
// набор полей тот же, значения новые. Тай-брейк по канонической форме
|
||||
// заморозил бы её на произвольной версии навсегда.
|
||||
func TestMergeДосчётПриТомЖеНабореПолейПобеждает(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
mergeWorkouts(t, st, from(t, "d1", "2025-06-05T08:00:00Z"), workout(t, "w1", woWithRoute))
|
||||
stats := mergeWorkouts(t, st, from(t, "d2", "2025-06-05T08:05:00Z"), workout(t, "w1", woSameShapeNewValues))
|
||||
|
||||
if stats.WorkoutsWritten != 1 {
|
||||
t.Errorf("записано %d, ожидалась 1", stats.WorkoutsWritten)
|
||||
}
|
||||
if got := storedRaw(t, st, "w1"); got != woSameShapeNewValues {
|
||||
t.Error("досчитанная версия не легла в витрину")
|
||||
}
|
||||
}
|
||||
|
||||
// Исход обязан быть функцией ЖУРНАЛА, а не порядка свёртки: воркер сворачивает
|
||||
// в порядке журнала только среди видимых ему доставок, и доставка с более
|
||||
// ранней меткой может свернуться позже.
|
||||
func TestMergeВерсияИзБолееРаннейДоставкиНеОткатываетВитрину(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
early := from(t, "d1", "2025-06-05T08:00:00Z")
|
||||
late := from(t, "d2", "2025-06-05T08:05:00Z")
|
||||
|
||||
прямой := open(t)
|
||||
mergeWorkouts(t, прямой, early, workout(t, "w1", woWithRoute))
|
||||
mergeWorkouts(t, прямой, late, workout(t, "w1", woSameShapeNewValues))
|
||||
|
||||
обратный := open(t)
|
||||
mergeWorkouts(t, обратный, late, workout(t, "w1", woSameShapeNewValues))
|
||||
mergeWorkouts(t, обратный, early, workout(t, "w1", woWithRoute))
|
||||
|
||||
a, b := storedRaw(t, прямой, "w1"), storedRaw(t, обратный, "w1")
|
||||
if a != b {
|
||||
t.Error("исход зависит от порядка свёртки — живая витрина разойдётся с пересборкой")
|
||||
}
|
||||
if a != woSameShapeNewValues {
|
||||
t.Error("победила версия не из более поздней доставки журнала")
|
||||
}
|
||||
}
|
||||
|
||||
// Маршрут — 95% содержимого тренировки, а восстановление требует пересборки
|
||||
// всего журнала. Событие делается наблюдаемым, а не необратимым.
|
||||
func TestMergeОбеднённаяВерсияНеЗатираетСохранённую(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
mergeWorkouts(t, st, from(t, "d1", "2025-06-05T08:00:00Z"), workout(t, "w1", woWithRoute))
|
||||
stats := mergeWorkouts(t, st, from(t, "d2", "2025-06-05T08:05:00Z"), workout(t, "w1", woNoRouteNewValues))
|
||||
|
||||
if stats.WorkoutsWritten != 0 {
|
||||
t.Errorf("записано %d, ожидалось 0: приехавшая теряет маршрут", stats.WorkoutsWritten)
|
||||
}
|
||||
if stats.EntitiesHeld != 1 {
|
||||
t.Errorf("удержано %d, ожидалась 1 — событие обязано быть видно", stats.EntitiesHeld)
|
||||
}
|
||||
if len(stats.HeldAt) != 1 || stats.HeldAt[0].ID != "w1" {
|
||||
t.Errorf("координаты удержанной версии %v", stats.HeldAt)
|
||||
}
|
||||
if got := storedRaw(t, st, "w1"); got != woWithRoute {
|
||||
t.Error("маршрут пропал из витрины")
|
||||
}
|
||||
}
|
||||
|
||||
// Усечённый маршрут ключа не теряет — множеств ключей мало.
|
||||
func TestMergeУсечённыйМаршрутНеЗатираетСохранённый(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
mergeWorkouts(t, st, from(t, "d1", "2025-06-05T08:00:00Z"), workout(t, "w1", woWithRoute))
|
||||
stats := mergeWorkouts(t, st, from(t, "d2", "2025-06-05T08:05:00Z"), workout(t, "w1", woShortRoute))
|
||||
|
||||
if stats.EntitiesHeld != 1 {
|
||||
t.Errorf("удержано %d, ожидалась 1", stats.EntitiesHeld)
|
||||
}
|
||||
if got := storedRaw(t, st, "w1"); got != woWithRoute {
|
||||
t.Error("полный маршрут вытеснен усечённым")
|
||||
}
|
||||
}
|
||||
|
||||
// Несравнимые наборы: у каждой версии есть содержание, которого нет у другой.
|
||||
// Поля не объединяются, вместо этого — счётчик.
|
||||
func TestMergeНесравнимыеНаборыНеОбъединяются(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
mergeWorkouts(t, st, from(t, "d1", "2025-06-05T08:00:00Z"), workout(t, "w1", woWithRoute))
|
||||
stats := mergeWorkouts(t, st, from(t, "d2", "2025-06-05T08:05:00Z"), workout(t, "w1", woIncomparable))
|
||||
|
||||
if stats.EntitiesHeld != 1 {
|
||||
t.Errorf("удержано %d, ожидалась 1", stats.EntitiesHeld)
|
||||
}
|
||||
if got := storedRaw(t, st, "w1"); got != woWithRoute {
|
||||
t.Error("несравнимая версия заместила сохранённую")
|
||||
}
|
||||
}
|
||||
|
||||
// Хеш — детектор изменений: тренировка переприсылается каждой доставкой, пока
|
||||
// не доедет маршрут, и 41 копия из 44 записи вызывать не должна.
|
||||
func TestMergeПовторТойЖеТренировкиНеПишет(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
mergeWorkouts(t, st, from(t, "d1", "2025-06-05T08:00:00Z"), workout(t, "w1", woWithRoute))
|
||||
|
||||
// Тот же смысл, другой порядок ключей и дребезг литерала: каноническая
|
||||
// форма обязана совпасть.
|
||||
same := `{"name":"На улице Ходьба","id":"w1","totalEnergy":{"units":"kJ","qty":20.0},
|
||||
"activeEnergy":[{"qty":10}],"route":[{"lat":1},{"lat":2},{"lat":3}]}`
|
||||
stats := mergeWorkouts(t, st, from(t, "d2", "2025-06-05T08:05:00Z"), workout(t, "w1", same))
|
||||
|
||||
if stats.WorkoutsWritten != 0 {
|
||||
t.Errorf("записано %d, ожидалось 0: содержимое то же", stats.WorkoutsWritten)
|
||||
}
|
||||
}
|
||||
|
||||
// Три версии в разных порядках подачи: пункты правила, не зависящие от порядка
|
||||
// свёртки, обязаны давать одно состояние. Конвенция требует перестановки трёх,
|
||||
// а не пары: попарная свёртка уже давала нетранзитивную победу на точках.
|
||||
func TestMergeПерестановкаТрёхВерсийДаётОдноСостояние(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type step struct {
|
||||
d store.DeliveryRef
|
||||
raw string
|
||||
}
|
||||
steps := []step{
|
||||
{from(t, "d1", "2025-06-05T08:00:00Z"), woNoRouteNewValues},
|
||||
{from(t, "d2", "2025-06-05T08:05:00Z"), woWithRoute},
|
||||
{from(t, "d3", "2025-06-05T08:10:00Z"), woRicher},
|
||||
}
|
||||
orders := [][]int{{0, 1, 2}, {2, 1, 0}, {1, 0, 2}, {1, 2, 0}, {2, 0, 1}, {0, 2, 1}}
|
||||
|
||||
var want string
|
||||
for i, order := range orders {
|
||||
st := open(t)
|
||||
for _, idx := range order {
|
||||
mergeWorkouts(t, st, steps[idx].d, workout(t, "w1", steps[idx].raw))
|
||||
}
|
||||
got := storedRaw(t, st, "w1")
|
||||
if i == 0 {
|
||||
want = got
|
||||
if want != woRicher {
|
||||
t.Fatalf("победила не самая полная версия")
|
||||
}
|
||||
continue
|
||||
}
|
||||
if got != want {
|
||||
t.Errorf("порядок %v дал другое состояние", order)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Порядок элементов в JSON-массиве нестабилен, поэтому две версии одного ключа
|
||||
// внутри одной доставки не имеют права разрешаться «последним в массиве».
|
||||
func TestMergeДвеВерсииВОдномТелеНеЗависятОтПорядка(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
d := from(t, "d1", "2025-06-05T08:00:00Z")
|
||||
|
||||
прямой := open(t)
|
||||
mergeWorkouts(t, прямой, d, workout(t, "w1", woWithRoute), workout(t, "w1", woSameShapeNewValues))
|
||||
|
||||
обратный := open(t)
|
||||
mergeWorkouts(t, обратный, d, workout(t, "w1", woSameShapeNewValues), workout(t, "w1", woWithRoute))
|
||||
|
||||
if storedRaw(t, прямой, "w1") != storedRaw(t, обратный, "w1") {
|
||||
t.Error("исход зависит от порядка элементов в массиве секции")
|
||||
}
|
||||
}
|
||||
|
||||
// Записи разных родов с одним идентификатором — разные записи: ключ пара, а не
|
||||
// один id.
|
||||
func TestMergeЗаписиРазныхРодовСОднимIDНеСталкиваются(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
ctx := context.Background()
|
||||
|
||||
mind := store.IncomingEntity{
|
||||
ID: "e1", Kind: "stateOfMind",
|
||||
Start: ts(t, "2025-06-05T18:00:00Z"),
|
||||
End: ts(t, "2025-06-05T18:00:00Z"),
|
||||
Raw: json.RawMessage(`{"id":"e1","kind":"momentary_emotion","valence":0.5}`),
|
||||
}
|
||||
ecg := store.IncomingEntity{
|
||||
ID: "e1", Kind: "ecg",
|
||||
Start: ts(t, "2025-06-05T19:00:00Z"),
|
||||
End: ts(t, "2025-06-05T19:00:00Z"),
|
||||
Raw: json.RawMessage(`{"id":"e1","classification":"sinusRhythm"}`),
|
||||
}
|
||||
if _, err := st.Merge(ctx, store.Incoming{Records: []store.IncomingEntity{mind, ecg}},
|
||||
from(t, "d1", "2025-06-05T20:00:00Z")); err != nil {
|
||||
t.Fatalf("слияние записей: %v", err)
|
||||
}
|
||||
|
||||
for _, kind := range []string{"stateOfMind", "ecg"} {
|
||||
if _, err := st.Record(ctx, kind, "e1"); err != nil {
|
||||
t.Errorf("запись рода %q не найдена: %v", kind, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Отпечаток — единственный оракул сходимости. Витрины, совпадающие по часовым
|
||||
// объектам, но разошедшиеся в тренировке, обязаны давать разные отпечатки.
|
||||
func TestFingerprintРазличаетТренировки(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
build := func(raw string) string {
|
||||
st := open(t)
|
||||
in := store.Incoming{
|
||||
Points: []store.IncomingPoint{point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":1}`)},
|
||||
Workouts: []store.IncomingEntity{workout(t, "w1", raw)},
|
||||
}
|
||||
if _, err := st.Merge(ctx, in, from(t, "d1", "2025-06-05T08:00:00Z")); err != nil {
|
||||
t.Fatalf("слияние: %v", err)
|
||||
}
|
||||
fp, err := st.Fingerprint(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("отпечаток: %v", err)
|
||||
}
|
||||
return fp
|
||||
}
|
||||
|
||||
full, changed := build(woWithRoute), build(woSameShapeNewValues)
|
||||
if full == changed {
|
||||
t.Error("отпечатки совпали при разошедшемся содержимом тренировки")
|
||||
}
|
||||
if again := build(woWithRoute); again != full {
|
||||
t.Error("отпечаток не воспроизводится на одном содержимом")
|
||||
}
|
||||
}
|
||||
|
||||
// Длительность: ноль — законное измерение, а «не прислали» обязано быть
|
||||
// отличимо от него.
|
||||
func TestWorkoutДлительностьОтличаетНольОтОтсутствия(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
ctx := context.Background()
|
||||
|
||||
zero := 0.0
|
||||
withZero := workout(t, "w-zero", `{"id":"w-zero","qty":1}`)
|
||||
withZero.Duration = &zero
|
||||
withNone := workout(t, "w-none", `{"id":"w-none","qty":1}`)
|
||||
|
||||
if _, err := st.Merge(ctx, store.Incoming{Workouts: []store.IncomingEntity{withZero, withNone}},
|
||||
from(t, "d1", "2025-06-05T08:00:00Z")); err != nil {
|
||||
t.Fatalf("слияние: %v", err)
|
||||
}
|
||||
|
||||
w, err := st.Workout(ctx, "w-zero")
|
||||
if err != nil {
|
||||
t.Fatalf("чтение: %v", err)
|
||||
}
|
||||
if w.Duration == nil || *w.Duration != 0 {
|
||||
t.Errorf("нулевая длительность потерялась: %v", w.Duration)
|
||||
}
|
||||
w, err = st.Workout(ctx, "w-none")
|
||||
if err != nil {
|
||||
t.Fatalf("чтение: %v", err)
|
||||
}
|
||||
if w.Duration != nil {
|
||||
t.Errorf("отсутствие длительности стало значением %v", *w.Duration)
|
||||
}
|
||||
}
|
||||
|
||||
// Содержимое сущности хранится дословно: побайтовый круг «запись → чтение».
|
||||
func TestWorkoutСодержимоеХранитсяДословно(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
// Литералы, которые теряет любая пересборка через разобранные значения:
|
||||
// `1.0`, целое больше 2^53, дробь длиннее двенадцати значащих цифр, а также
|
||||
// символы, которые json.Marshal экранирует по умолчанию.
|
||||
const raw = `{"id":"w9","route":[{"lat":1.0,"big":9007199254740993,"p":0.123456789012345678}],
|
||||
"note":"a<b&c>d","isIndoor":false}`
|
||||
|
||||
if _, err := st.Merge(context.Background(),
|
||||
store.Incoming{Workouts: []store.IncomingEntity{workout(t, "w9", raw)}},
|
||||
from(t, "d1", "2025-06-05T08:00:00Z")); err != nil {
|
||||
t.Fatalf("слияние: %v", err)
|
||||
}
|
||||
if got := storedRaw(t, st, "w9"); got != raw {
|
||||
t.Errorf("содержимое изменилось при хранении:\nбыло %s\nстало %s", raw, got)
|
||||
}
|
||||
}
|
||||
|
||||
// Провенанс нужен не отчётности: по нему разрешается тай-брейк, и без него
|
||||
// запись WARN об удержанной версии не связать с телом в архиве.
|
||||
func TestWorkoutНесётПровенанс(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
mergeWorkouts(t, st, from(t, "d7", "2025-06-05T08:00:00Z"), workout(t, "w1", woWithRoute))
|
||||
|
||||
w, err := st.Workout(context.Background(), "w1")
|
||||
if err != nil {
|
||||
t.Fatalf("чтение: %v", err)
|
||||
}
|
||||
if w.Delivery != "d7" {
|
||||
t.Errorf("провенанс %q, ожидался d7", w.Delivery)
|
||||
}
|
||||
if w.Start.IsZero() || w.End.Before(w.Start) {
|
||||
t.Errorf("интервал заголовка неверен: %v — %v", w.Start, w.End)
|
||||
}
|
||||
if w.OffsetSeconds != 3*3600 {
|
||||
t.Errorf("офсет %d, ожидался 10800", w.OffsetSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
// Внутри одной доставки «сохранённой» версии не существует — есть только
|
||||
// порядок элементов в JSON-массиве, а он нестабилен. Ни исход, ни счётчик не
|
||||
// имеют права от него зависеть.
|
||||
func TestMergeНесравнимыеВерсииВОдномТелеНеЗависятОтПорядка(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
d := from(t, "d1", "2025-06-05T08:00:00Z")
|
||||
const withRoute = `{"id":"w1","route":[{"lat":1}],"stepCount":{"qty":9}}`
|
||||
const withFlights = `{"id":"w1","route":[{"lat":1}],"flightsClimbed":{"qty":3}}`
|
||||
|
||||
прямой := open(t)
|
||||
a := mergeWorkouts(t, прямой, d, workout(t, "w1", withRoute), workout(t, "w1", withFlights))
|
||||
|
||||
обратный := open(t)
|
||||
b := mergeWorkouts(t, обратный, d, workout(t, "w1", withFlights), workout(t, "w1", withRoute))
|
||||
|
||||
if storedRaw(t, прямой, "w1") != storedRaw(t, обратный, "w1") {
|
||||
t.Error("исход зависит от порядка элементов в массиве секции")
|
||||
}
|
||||
if a.EntitiesHeld != b.EntitiesHeld {
|
||||
t.Errorf("счётчик зависит от порядка: %d против %d", a.EntitiesHeld, b.EntitiesHeld)
|
||||
}
|
||||
if a.EntitiesHeld == 0 {
|
||||
t.Error("две версии с разным содержанием в одном теле остались незамеченными")
|
||||
}
|
||||
}
|
||||
|
||||
// Отпечаток обязан различать состояния, а не только содержимое: составной ключ
|
||||
// записи, склеенный до взятия длины, даёт (`a`, `b/c`) = (`a/b`, `c`).
|
||||
func TestFingerprintРазличаетСоставнойКлючЗаписи(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
build := func(kind, id string) string {
|
||||
st := open(t)
|
||||
rec := store.IncomingEntity{
|
||||
ID: id, Kind: kind,
|
||||
Start: ts(t, "2025-06-05T18:00:00Z"),
|
||||
End: ts(t, "2025-06-05T18:00:00Z"),
|
||||
Raw: json.RawMessage(`{"id":"x","valence":0.5}`),
|
||||
}
|
||||
if _, err := st.Merge(ctx, store.Incoming{Records: []store.IncomingEntity{rec}},
|
||||
from(t, "d1", "2025-06-05T08:00:00Z")); err != nil {
|
||||
t.Fatalf("слияние: %v", err)
|
||||
}
|
||||
fp, err := st.Fingerprint(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("отпечаток: %v", err)
|
||||
}
|
||||
return fp
|
||||
}
|
||||
|
||||
if build("a", "b/c") == build("a/b", "c") {
|
||||
t.Error("два разных состояния витрины дали один отпечаток: составной ключ склеен до взятия длины")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user