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") }