package prosediff
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// changeSummary is a compact, readable expectation: one string per change.
func changeSummary(d *Diff) []string {
var out []string
for _, c := range d.Changes {
if c.Kind == ChangeEqual {
continue
}
b := c.New
if b == nil {
b = c.Old
}
out = append(out, string(c.Kind)+" "+string(b.Kind))
}
return out
}
// inlineOf returns the word-level diff of the n-th modified block, rendered
// with markers, so tests can assert on what the reviewer would actually see.
func inlineOf(t *testing.T, d *Diff, n int) string {
t.Helper()
seen := 0
for _, c := range d.Changes {
if c.Kind != ChangeModify {
continue
}
if seen != n {
seen++
continue
}
var sb strings.Builder
for _, s := range c.Words {
if s.Space && sb.Len() > 0 {
sb.WriteByte(' ')
}
switch s.Op {
case OpEqual:
sb.WriteString(s.Text)
case OpDelete:
sb.WriteString("[-" + s.Text + "-]")
case OpInsert:
sb.WriteString("{+" + s.Text + "+}")
}
}
return sb.String()
}
t.Fatalf("no modified block #%d in diff", n)
return ""
}
func TestCompare(t *testing.T) {
tests := []struct {
name string
old, new string
want []string // non-equal changes, "kind blockkind"
inline string // expected rendering of the first modification
stats func(t *testing.T, s Stats)
}{
{
name: "pure reflow is a no-op",
old: "# Doc\n\nMarkdown reflows. A one-word edit renders as a whole-paragraph\n" +
"replace under a line-oriented differ, which makes reviewing agent\noutput miserable.\n",
new: "# Doc\n\nMarkdown reflows. A one-word edit renders as a\nwhole-paragraph replace under a line-oriented differ,\nwhich makes reviewing agent output miserable.\n",
want: nil,
stats: func(t *testing.T, s Stats) {
assert.False(t, s.Changed())
assert.Equal(t, 2, s.BlocksEqual)
},
},
{
name: "single word edit inside a long reflowed paragraph",
old: "Markdown reflows. A one-word edit renders as a whole-paragraph replace\n" +
"under a line-oriented differ, which makes reviewing agent output\nmiserable.\n",
new: "Markdown reflows. A one-word edit renders as a\nwhole-paragraph replace under a line-oriented differ, which makes\n" +
"reviewing agent output unbearable.\n",
want: []string{"modify paragraph"},
inline: "Markdown reflows. A one-word edit renders as a whole-paragraph replace under a line-oriented differ, which makes reviewing agent output [-miserable-]{+unbearable+}.",
stats: func(t *testing.T, s Stats) {
assert.Equal(t, 1, s.BlocksModified)
assert.Equal(t, 1, s.WordsInserted)
assert.Equal(t, 1, s.WordsDeleted)
},
},
{
name: "paragraph added",
old: "One.\n\nThree.\n",
new: "One.\n\nA brand new second paragraph goes here.\n\nThree.\n",
want: []string{"insert paragraph"},
stats: func(t *testing.T, s Stats) {
assert.Equal(t, 1, s.BlocksInserted)
assert.Equal(t, 2, s.BlocksEqual)
},
},
{
name: "paragraph removed",
old: "One.\n\nA whole paragraph that is going away entirely.\n\nThree.\n",
new: "One.\n\nThree.\n",
want: []string{"delete paragraph"},
stats: func(t *testing.T, s Stats) {
assert.Equal(t, 1, s.BlocksDeleted)
},
},
{
name: "heading text changed",
old: "## Prose diff, not line diff\n\nbody\n",
new: "## Prose diff, never line diff\n\nbody\n",
want: []string{"modify heading"},
inline: "Prose diff, [-not-]{+never+} line diff",
},
{
name: "heading level changed only",
old: "## The two hard parts\n\nbody\n",
new: "### The two hard parts\n\nbody\n",
want: []string{"modify heading"},
},
{
name: "list item edited, siblings untouched",
old: "- alpha stays exactly the same\n- beta gets a small correction here\n- gamma stays too\n",
new: "- alpha stays exactly the same\n- beta gets a large correction here\n- gamma stays too\n",
want: []string{"modify list_item"},
inline: "beta gets a [-small-]{+large+} correction here",
stats: func(t *testing.T, s Stats) {
assert.Equal(t, 2, s.BlocksEqual)
assert.Equal(t, 1, s.BlocksModified)
},
},
{
name: "list item added",
old: "- alpha stays the same\n- gamma stays the same\n",
new: "- alpha stays the same\n- beta is entirely new here\n- gamma stays the same\n",
want: []string{"insert list_item"},
},
{
name: "list nesting change is structural",
old: "- alpha the first item\n- beta the second item\n",
new: "- alpha the first item\n - beta the second item\n",
want: []string{"modify list_item"},
},
{
name: "code fence line edited",
old: "```go\nx := 1\ny := 2\nz := 3\n```\n",
new: "```go\nx := 1\ny := 22\nz := 3\n```\n",
want: []string{"modify code"},
},
{
name: "code fence indentation matters",
old: "```py\nif x:\n y()\n```\n",
new: "```py\nif x:\n\ty()\n```\n",
want: []string{"modify code"},
},
{
name: "code fence added",
old: "Some prose here.\n",
new: "Some prose here.\n\n```sh\nmake build\n```\n",
want: []string{"insert code"},
},
{
name: "table row edited",
old: "| Decision | Choice |\n|---|---|\n| Review gate | Proposal-first |\n| Storage | Own bare git repos |\n",
new: "| Decision | Choice |\n|---|---|\n| Review gate | Proposal-first |\n| Storage | Own bare git repos, service-owned |\n",
want: []string{"modify table_row"},
inline: "| Storage | Own bare git repos{+, service-owned+} |",
},
{
name: "table row added",
old: "| a | b |\n|---|---|\n| one | two |\n",
new: "| a | b |\n|---|---|\n| one | two |\n| three | four |\n",
want: []string{"insert table_row"},
},
{
name: "block quote edited",
old: "> bot produces, human curates, bots consume.\n",
new: "> bot produces, human reviews, bots consume.\n",
want: []string{"modify paragraph"},
inline: "bot produces, human [-curates-]{+reviews+}, bots consume.",
},
{
name: "frontmatter edited",
old: "---\nid: SPEC-0007\nstatus: draft\n---\n\nBody text.\n",
new: "---\nid: SPEC-0007\nstatus: review\n---\n\nBody text.\n",
want: []string{"modify frontmatter"},
},
{
name: "unrelated replacement is not a modification",
old: "The quick brown fox jumps over the lazy dog.\n",
new: "Consistency and recovery is the section that follows.\n",
want: []string{"delete paragraph", "insert paragraph"},
},
{
name: "empty to content",
old: "",
new: "# New\n\nBody.\n",
want: []string{"insert heading", "insert paragraph"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
d := Compare([]byte(tc.old), []byte(tc.new))
assert.Equal(t, tc.want, changeSummary(d))
if tc.inline != "" {
assert.Equal(t, tc.inline, inlineOf(t, d, 0))
}
if tc.stats != nil {
tc.stats(t, d.Stats)
}
// The renderer must survive every case.
assert.NotPanics(t, func() { RenderText(d, DefaultRenderOptions()) })
})
}
}
// TestWholeDocumentReflowIsSilent is the design's central claim, checked over
// a document with every block kind in it.
func TestWholeDocumentReflowIsSilent(t *testing.T) {
src := `# Title
A paragraph that is wrapped at some particular width and says several
things across more than one line of source text.
- a list item long enough to be rewrapped by an editor at some point
- another list item
> a block quote that also happens to be wrapped across two source
> lines here
| a | b |
|---|---|
| 1 | 2 |
` + "```go\nkeep := \"this exactly\"\n```\n"
rewrapped := rewrapProse(src, 40)
require.NotEqual(t, src, rewrapped, "test fixture did not actually rewrap")
d := Compare([]byte(src), []byte(rewrapped))
assert.False(t, d.Stats.Changed(), "rewrapping produced: %s", RenderText(d, DefaultRenderOptions()))
}
// rewrapProse rewraps paragraph, list and quote lines to width, leaving fenced
// code and table rows alone.
func rewrapProse(src string, width int) string {
var out []string
inFence := false
var para []string
var prefix string
flush := func() {
if len(para) == 0 {
return
}
cont := prefix
if prefix == "- " {
cont = " " // continuation of a list item, not a new one
}
for i, l := range wrap(strings.Join(para, " "), width) {
if i == 0 {
out = append(out, prefix+l)
continue
}
out = append(out, cont+l)
}
para = nil
prefix = ""
}
for _, line := range strings.Split(src, "\n") {
switch {
case strings.HasPrefix(line, "```"):
flush()
inFence = !inFence
out = append(out, line)
case inFence, strings.HasPrefix(line, "|"), strings.HasPrefix(line, "#"), strings.TrimSpace(line) == "":
flush()
out = append(out, line)
case strings.HasPrefix(line, "> "):
prefix = "> "
para = append(para, strings.TrimPrefix(line, "> "))
case strings.HasPrefix(line, "- "):
flush()
prefix = "- "
para = append(para, strings.TrimPrefix(line, "- "))
default:
para = append(para, strings.TrimSpace(line))
}
}
flush()
return strings.Join(out, "\n")
}
func TestMoveDetection(t *testing.T) {
a := "# Doc\n\n## Alpha\n\nThe alpha section body, long enough to be recognised.\n\n## Beta\n\nThe beta section body, also long enough to be recognised.\n"
b := "# Doc\n\n## Beta\n\nThe beta section body, also long enough to be recognised.\n\n## Alpha\n\nThe alpha section body, long enough to be recognised.\n"
d := Compare([]byte(a), []byte(b))
var kinds []ChangeKind
for _, c := range d.Changes {
if c.Kind != ChangeEqual {
kinds = append(kinds, c.Kind)
}
}
require.NotEmpty(t, kinds)
for _, k := range kinds {
assert.Contains(t, []ChangeKind{ChangeMoveIn, ChangeMoveOut}, k,
"a pure reorder should be moves only, got %v\n%s", kinds, RenderText(d, DefaultRenderOptions()))
}
assert.Equal(t, 2, d.Stats.BlocksMoved)
}
// TestMoveGroupBridgesOneEditedBlock covers the common real case: a section
// is moved and one paragraph inside it is touched. The untouched blocks must
// stay moves and the touched one must be a modification, not four inserts.
func TestMoveGroupBridgesOneEditedBlock(t *testing.T) {
// Alpha is the section that moves; Beta is deliberately the larger one,
// so the alignment keeps Beta in place and Alpha is what has to be
// recognised as moved.
sectionA := "## Alpha\n\nAlpha intro paragraph, long enough to anchor a move.\n\n" +
"Alpha middle paragraph that will be edited slightly.\n\n" +
"Alpha closing paragraph, also long enough to anchor a move.\n"
sectionB := "## Beta\n\nBeta first paragraph, long enough to anchor a move too.\n\n" +
"Beta second paragraph, long enough to anchor a move too.\n\n" +
"Beta third paragraph, long enough to anchor a move too.\n\n" +
"Beta fourth paragraph, long enough to anchor a move too.\n\n" +
"Beta fifth paragraph, long enough to anchor a move too.\n"
a := "# Doc\n\n" + sectionA + "\n" + sectionB
b := "# Doc\n\n" + sectionB + "\n" +
strings.Replace(sectionA, "edited slightly", "edited a little", 1)
d := Compare([]byte(a), []byte(b))
var modified []BlockChange
for _, c := range d.Changes {
switch c.Kind {
case ChangeModify:
modified = append(modified, c)
case ChangeInsert, ChangeDelete:
t.Fatalf("unexpected %s in a move+edit:\n%s", c.Kind, RenderText(d, DefaultRenderOptions()))
}
}
require.Len(t, modified, 1)
assert.True(t, modified[0].Moved, "the edited block should be marked as moved too")
assert.Equal(t, 3, d.Stats.BlocksMoved)
}
// TestMovedAndEditedIsNotAMove documents the honest limitation: content that
// moved *and* changed, with no verbatim block left to anchor it, shows up as
// a delete plus an insert.
func TestMovedAndEditedIsNotAMove(t *testing.T) {
a := "## Alpha\n\nThe alpha body here.\n\n## Beta\n\nThe beta body here.\n"
b := "## Beta\n\nThe beta body here.\n\n## Alpha\n\nThe alpha body here, now with a tail.\n"
d := Compare([]byte(a), []byte(b))
assert.Equal(t, 0, d.Stats.BlocksMoved,
"edited-while-moved must not be claimed as a move:\n%s", RenderText(d, DefaultRenderOptions()))
}
// TestHeadingRenameDoesNotDirtyItsSection: HeadingPath is context for the
// reviewer, never part of a block's identity. Including it would make
// renaming a section rewrite every block underneath it.
func TestHeadingRenameDoesNotDirtyItsSection(t *testing.T) {
old := "## The old section name\n\nFirst paragraph of the section.\n\nSecond paragraph of the section.\n"
nw := "## The new section name\n\nFirst paragraph of the section.\n\nSecond paragraph of the section.\n"
d := Compare([]byte(old), []byte(nw))
assert.Equal(t, 2, d.Stats.BlocksEqual)
assert.Equal(t, 1, d.Stats.BlocksModified)
}
// TestShortBlockRenameFallsBackToAddRemove pins the other side of the
// similarity floor: two blocks too short to judge are reported as a removal
// and an addition rather than paired on a coin-flip score.
func TestShortBlockRenameFallsBackToAddRemove(t *testing.T) {
d := Compare([]byte("## Old name\n\nbody\n"), []byte("## New name\n\nbody\n"))
assert.Equal(t, 0, d.Stats.BlocksModified)
assert.Equal(t, 1, d.Stats.BlocksInserted)
assert.Equal(t, 1, d.Stats.BlocksDeleted)
}
func TestHeadingPathIsCarried(t *testing.T) {
src := "# Top\n\n## Middle\n\n### Leaf\n\nbody\n"
blocks := Segment([]byte(src))
last := blocks[len(blocks)-1]
assert.Equal(t, []string{"Top", "Middle", "Leaf"}, last.HeadingPath)
}
func TestRenderText(t *testing.T) {
d := Compare(
[]byte("# Title\n\nThe quick brown fox jumps over the lazy dog every single day.\n"),
[]byte("# Title\n\nThe quick red fox jumps over the lazy dog every single day.\n"),
)
out := RenderText(d, DefaultRenderOptions())
assert.Contains(t, out, "@@ Title @@")
assert.Contains(t, out, "[-brown-]")
assert.Contains(t, out, "{+red+}")
assert.NotContains(t, out, "\n L1 h1") // equal blocks hidden by default
full := RenderText(d, RenderOptions{Width: 80, ShowEqual: true})
assert.Contains(t, full, "Title")
}
func TestRenderTextCodeIsLineOriented(t *testing.T) {
d := Compare(
[]byte("```go\na := 1\nb := 2\n```\n"),
[]byte("```go\na := 1\nb := 3\n```\n"),
)
out := RenderText(d, DefaultRenderOptions())
assert.Contains(t, out, " - b := 2")
assert.Contains(t, out, " + b := 3")
}