From 984d92e3a9997699446c2e73b48622da33db1558 Mon Sep 17 00:00:00 2001 From: Mathias Date: Thu, 1 Oct 2026 23:15:44 +0200 Subject: [PATCH] test(reconcile): word-level diff is minimal, tiles both inputs and is fast (red) A random-input property test compares the equal runs against a dynamic-programming LCS: a diff that is valid but not minimal would turn real agreement into disputes the model is then asked about. A 6,500-word, 380-edit case mirrors a real hour. Co-Authored-By: Claude Sonnet 5.5 --- internal/reconcile/diff_test.go | 139 ++++++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 internal/reconcile/diff_test.go diff --git a/internal/reconcile/diff_test.go b/internal/reconcile/diff_test.go new file mode 100644 index 0000000..c75204e --- /dev/null +++ b/internal/reconcile/diff_test.go @@ -0,0 +1,139 @@ +package reconcile_test + +import ( + "math/rand" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "git.d-ma.be/mathias/tapir/internal/reconcile" +) + +func words(s string) []string { return strings.Fields(s) } + +// apply rebuilds b from a using the ops, copying equal runs from a and the +// replacement/insertion runs from b. If Diff is right this must equal b. +func apply(a, b []string, ops []reconcile.Op) []string { + var out []string + for _, o := range ops { + switch o.Kind { + case reconcile.Equal: + out = append(out, a[o.AStart:o.AEnd]...) + default: + out = append(out, b[o.BStart:o.BEnd]...) + } + } + return out +} + +func TestDiff_Cases(t *testing.T) { + tests := []struct { + name string + a, b string + want []reconcile.Op + }{ + {"identical", "a b c", "a b c", []reconcile.Op{{Kind: reconcile.Equal, AStart: 0, AEnd: 3, BStart: 0, BEnd: 3}}}, + {"both empty", "", "", nil}, + {"one word replaced", "a b c", "a x c", []reconcile.Op{ + {Kind: reconcile.Equal, AStart: 0, AEnd: 1, BStart: 0, BEnd: 1}, + {Kind: reconcile.Replace, AStart: 1, AEnd: 2, BStart: 1, BEnd: 2}, + {Kind: reconcile.Equal, AStart: 2, AEnd: 3, BStart: 2, BEnd: 3}, + }}, + {"word only in b", "a c", "a b c", []reconcile.Op{ + {Kind: reconcile.Equal, AStart: 0, AEnd: 1, BStart: 0, BEnd: 1}, + {Kind: reconcile.Insert, AStart: 1, AEnd: 1, BStart: 1, BEnd: 2}, + {Kind: reconcile.Equal, AStart: 1, AEnd: 2, BStart: 2, BEnd: 3}, + }}, + {"word only in a", "a b c", "a c", []reconcile.Op{ + {Kind: reconcile.Equal, AStart: 0, AEnd: 1, BStart: 0, BEnd: 1}, + {Kind: reconcile.Delete, AStart: 1, AEnd: 2, BStart: 1, BEnd: 1}, + {Kind: reconcile.Equal, AStart: 2, AEnd: 3, BStart: 1, BEnd: 2}, + }}, + {"nothing in common", "a b", "x y z", []reconcile.Op{ + {Kind: reconcile.Replace, AStart: 0, AEnd: 2, BStart: 0, BEnd: 3}, + }}, + {"a is empty", "", "x y", []reconcile.Op{{Kind: reconcile.Insert, AStart: 0, AEnd: 0, BStart: 0, BEnd: 2}}}, + {"b is empty", "x y", "", []reconcile.Op{{Kind: reconcile.Delete, AStart: 0, AEnd: 2, BStart: 0, BEnd: 0}}}, + {"adjacent delete and insert read as one replace", "a b c d", "a x y d", []reconcile.Op{ + {Kind: reconcile.Equal, AStart: 0, AEnd: 1, BStart: 0, BEnd: 1}, + {Kind: reconcile.Replace, AStart: 1, AEnd: 3, BStart: 1, BEnd: 3}, + {Kind: reconcile.Equal, AStart: 3, AEnd: 4, BStart: 3, BEnd: 4}, + }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, reconcile.Diff(words(tt.a), words(tt.b))) + }) + } +} + +// lcsLen is a plain dynamic-programming reference. Myers must find an edit script +// whose equal runs add up to exactly this: a diff that is merely valid but not +// minimal would turn real agreement into disputes the adjudicator is then asked about. +func lcsLen(a, b []string) int { + prev := make([]int, len(b)+1) + for i := 1; i <= len(a); i++ { + cur := make([]int, len(b)+1) + for j := 1; j <= len(b); j++ { + if a[i-1] == b[j-1] { + cur[j] = prev[j-1] + 1 + } else { + cur[j] = max(prev[j], cur[j-1]) + } + } + prev = cur + } + return prev[len(b)] +} + +func TestDiff_RebuildsBAndKeepsEveryCommonWordOnRandomInputs(t *testing.T) { + rng := rand.New(rand.NewSource(7)) + alphabet := []string{"a", "b", "c", "d", "e", "f"} + gen := func(n int) []string { + out := make([]string, n) + for i := range out { + out[i] = alphabet[rng.Intn(len(alphabet))] + } + return out + } + for i := 0; i < 400; i++ { + a, b := gen(rng.Intn(25)), gen(rng.Intn(25)) + + ops := reconcile.Diff(a, b) + + require.Equal(t, append([]string{}, b...), append([]string{}, apply(a, b, ops)...), "a=%v b=%v ops=%v", a, b, ops) + common := 0 + prevA, prevB := 0, 0 + for _, o := range ops { + require.Equal(t, prevA, o.AStart, "ops must tile a without gaps: %v", ops) + require.Equal(t, prevB, o.BStart, "ops must tile b without gaps: %v", ops) + prevA, prevB = o.AEnd, o.BEnd + if o.Kind == reconcile.Equal { + common += o.AEnd - o.AStart + require.Equal(t, a[o.AStart:o.AEnd], b[o.BStart:o.BEnd], "an Equal op must really be equal") + } + } + require.Equal(t, len(a), prevA) + require.Equal(t, len(b), prevB) + require.Equal(t, lcsLen(a, b), common, "the diff must be minimal: a=%v b=%v", a, b) + } +} + +func TestDiff_HandlesAnHoursWorthOfWordsFast(t *testing.T) { + rng := rand.New(rand.NewSource(3)) + a := make([]string, 6500) + for i := range a { + a[i] = string(rune('a'+rng.Intn(26))) + string(rune('a'+rng.Intn(26))) + } + b := append([]string{}, a...) + for i := 0; i < 380; i++ { // ~380 disputes, as measured on a real hour + b[rng.Intn(len(b))] = "zz" + string(rune('a'+rng.Intn(26))) + } + + ops := reconcile.Diff(a, b) + + assert.NotEmpty(t, ops) + assert.Equal(t, append([]string{}, b...), append([]string{}, apply(a, b, ops)...)) +}