полнота точки — множество ключей, победитель — функция множества точек
- отношение победы было нетранзитивным: полнота (частичный порядок) плюс тай-брейк (тотальный) в попарной свёртке давали цикл, из-за которого одна и та же доставка меняла содержимое объекта при каждой пересборке - надмножество побеждает только при совпадении значений общих содержательных ключей: иначе точка без единого измерения вытесняла измерение - Less стал тотальным, isEmpty не материализует значение, имя метрики в координате столкновения обрезается, отпечаток витрины включает units и sealed - на живом архиве строгий no-op: 1737 объектов, содержимое совпало побайтово
This commit is contained in:
+190
-16
@@ -1,7 +1,9 @@
|
||||
package canon_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"git.vakhrushev.me/av/healthlog/internal/canon"
|
||||
@@ -158,44 +160,216 @@ func TestFormНеСдвигаетБольшиеЦелые(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteness(t *testing.T) {
|
||||
func TestRelateFullness(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
raw string
|
||||
want int
|
||||
a string
|
||||
b string
|
||||
want canon.Fullness
|
||||
}{
|
||||
{"пустой объект", `{}`, 0},
|
||||
{"только qty", `{"qty":1}`, 1},
|
||||
{"qty и границы", `{"qty":1,"start":"a","end":"b"}`, 3},
|
||||
{"одинаковые", `{"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 не считается",
|
||||
raw: `{"qty":1,"source":"Device A"}`,
|
||||
want: 1,
|
||||
a: `{"qty":1,"source":"Device A"}`, b: `{"qty":1}`,
|
||||
want: canon.FullnessEqual,
|
||||
},
|
||||
{
|
||||
// Точка с пустым полем не полнее точки без него — иначе бедная
|
||||
// доставка выиграла бы столкновение одним лишь наличием ключа.
|
||||
name: "пустые значения не считаются",
|
||||
raw: `{"qty":1,"context":null,"note":""}`,
|
||||
want: 1,
|
||||
// Регрессия задачи: пять полей без содержания против настоящего
|
||||
// измерения. Счётчик давал 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,
|
||||
},
|
||||
{"не объект", `[1,2,3]`, 0},
|
||||
{
|
||||
// Второй разряд: содержательные ключи те же, но нулевые поля
|
||||
// теряться не должны.
|
||||
// Второй разряд работает именно при РАВНОМ содержании: 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()
|
||||
if got := canon.Completeness([]byte(c.raw)); got != c.want {
|
||||
t.Errorf("полнота %s = %d, ожидалось %d", c.raw, got, c.want)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// Тай-брейк обязан зависеть только от значений: свёртка по журналу должна
|
||||
// давать то же состояние, что приём в реальном времени, а внутри одной
|
||||
// доставки время приёма у столкнувшихся точек общее.
|
||||
|
||||
Reference in New Issue
Block a user