полнота точки — множество ключей, победитель — функция множества точек

- отношение победы было нетранзитивным: полнота (частичный порядок) плюс
  тай-брейк (тотальный) в попарной свёртке давали цикл, из-за которого одна
  и та же доставка меняла содержимое объекта при каждой пересборке
- надмножество побеждает только при совпадении значений общих содержательных
  ключей: иначе точка без единого измерения вытесняла измерение
- Less стал тотальным, isEmpty не материализует значение, имя метрики в
  координате столкновения обрезается, отпечаток витрины включает units и sealed
- на живом архиве строгий no-op: 1737 объектов, содержимое совпало побайтово
This commit is contained in:
av
2026-08-01 21:05:43 +03:00
parent 349a227ab1
commit 7a7594e3e7
22 changed files with 1818 additions and 176 deletions
+190 -16
View File
@@ -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)
}
}
// Тай-брейк обязан зависеть только от значений: свёртка по журналу должна
// давать то же состояние, что приём в реальном времени, а внутри одной
// доставки время приёма у столкнувшихся точек общее.