package prosediff
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type wantBlock struct {
kind BlockKind
level int
quote int
text string
path []string
}
func TestSegment(t *testing.T) {
tests := []struct {
name string
src string
want []wantBlock
}{
{
name: "headings and paragraphs",
src: "# Title\n\nFirst para\nwrapped over lines.\n\n## Sub\n\nSecond para.\n",
want: []wantBlock{
{kind: KindHeading, level: 1, text: "Title"},
{kind: KindParagraph, text: "First para\nwrapped over lines.", path: []string{"Title"}},
{kind: KindHeading, level: 2, text: "Sub", path: []string{"Title"}},
{kind: KindParagraph, text: "Second para.", path: []string{"Title", "Sub"}},
},
},
{
name: "list items are separate blocks",
src: "- alpha\n- beta\n - nested\n",
want: []wantBlock{
{kind: KindListItem, level: 1, text: "alpha"},
{kind: KindListItem, level: 1, text: "beta"},
{kind: KindListItem, level: 2, text: "nested"},
},
},
{
name: "fenced code is one block",
src: "text\n\n```go\na := 1\nb := 2\n```\n",
want: []wantBlock{
{kind: KindParagraph, text: "text"},
{kind: KindCode, text: "a := 1\nb := 2"},
},
},
{
name: "table rows are separate blocks",
src: "| a | b |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |\n",
want: []wantBlock{
{kind: KindTableHeader, text: "| a | b |"},
{kind: KindTableRow, text: "| 1 | 2 |"},
{kind: KindTableRow, text: "| 3 | 4 |"},
},
},
{
name: "block quote carries depth",
src: "> quoted text\n> continues\n",
want: []wantBlock{
{kind: KindParagraph, quote: 1, text: "quoted text\ncontinues"},
},
},
{
name: "frontmatter is its own block",
src: "---\nid: SPEC-0007\ntitle: x\n---\n\nBody.\n",
want: []wantBlock{
{kind: KindFrontmatter, text: "---\nid: SPEC-0007\ntitle: x\n---"},
{kind: KindParagraph, text: "Body."},
},
},
{
name: "empty document",
src: "",
want: nil,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := Segment([]byte(tc.src))
require.Len(t, got, len(tc.want))
for i, w := range tc.want {
b := got[i]
assert.Equal(t, w.kind, b.Kind, "block %d kind", i)
assert.Equal(t, w.level, b.Level, "block %d level", i)
assert.Equal(t, w.quote, b.QuoteDepth, "block %d quote depth", i)
assert.Equal(t, w.text, b.Text, "block %d text", i)
assert.Equal(t, w.path, b.HeadingPath, "block %d heading path", i)
assert.Equal(t, i, b.Ordinal)
assert.NotEmpty(t, b.Hash)
}
})
}
}
func TestSegmentLineNumbers(t *testing.T) {
src := "# Title\n\npara one\n\n```\ncode\n```\n"
got := Segment([]byte(src))
require.Len(t, got, 3)
assert.Equal(t, 1, got[0].StartLine)
assert.Equal(t, 3, got[1].StartLine)
assert.Equal(t, 6, got[2].StartLine)
}
func TestSegmentFrontmatterLineOffset(t *testing.T) {
src := "---\nid: X\n---\n\n# Title\n"
got := Segment([]byte(src))
require.Len(t, got, 2)
assert.Equal(t, KindFrontmatter, got[0].Kind)
assert.Equal(t, 5, got[1].StartLine)
}
// TestHashIgnoresWrappingForProse and its code counterpart are the two halves
// of the design's "code fences diff as code, prose diffs as prose" rule.
func TestHashIgnoresWrappingForProse(t *testing.T) {
a := Segment([]byte("one two three four\nfive six\n"))
b := Segment([]byte("one two\nthree four five six\n"))
require.Len(t, a, 1)
require.Len(t, b, 1)
assert.Equal(t, a[0].Hash, b[0].Hash)
}
func TestHashRespectsWhitespaceForCode(t *testing.T) {
a := Segment([]byte("```\nif x:\n y()\n```\n"))
b := Segment([]byte("```\nif x:\n\ty()\n```\n"))
require.Len(t, a, 1)
require.Len(t, b, 1)
assert.NotEqual(t, a[0].Hash, b[0].Hash)
}
func TestHashRespectsStructure(t *testing.T) {
para := Segment([]byte("same words here\n"))
item := Segment([]byte("- same words here\n"))
require.Len(t, para, 1)
require.Len(t, item, 1)
assert.NotEqual(t, para[0].Hash, item[0].Hash)
}
func TestKindProse(t *testing.T) {
for _, k := range []BlockKind{KindHeading, KindParagraph, KindListItem, KindTableRow, KindTableHeader, KindThematicBreak} {
assert.True(t, k.Prose(), "%s should diff as prose", k)
}
for _, k := range []BlockKind{KindCode, KindFrontmatter, KindHTML} {
assert.False(t, k.Prose(), "%s should diff as code", k)
}
}
// A thematic break carries no text and therefore no text segment, so its
// position has to be recovered from the source rather than read off the AST.
// Every rule used to report line 1 — including two rules in one document, which
// is the one thing a line-numbered renderer must never be told.
func TestThematicBreakReportsItsOwnLine(t *testing.T) {
src := "# One\n\npara\n\n---\n\nmore\n\n---\n\n> quoted\n>\n> ---\n\ntail\n"
var rules []Block
for _, b := range Segment([]byte(src)) {
if b.Kind == KindThematicBreak {
rules = append(rules, b)
}
}
require.Len(t, rules, 3)
assert.Equal(t, []int{5, 9, 13}, []int{rules[0].StartLine, rules[1].StartLine, rules[2].StartLine})
for _, r := range rules {
assert.Equal(t, r.StartLine, r.EndLine, "a rule occupies one line")
}
}
// Consecutive rules have nothing but their order to tell them apart, and are
// the case a naive "scan forward for the next rule" gets wrong by reporting the
// first one twice.
func TestAdjacentThematicBreaksDoNotShareALine(t *testing.T) {
var lines []int
for _, b := range Segment([]byte("a\n\n---\n\n---\n\n---\n\nb\n")) {
if b.Kind == KindThematicBreak {
lines = append(lines, b.StartLine)
}
}
assert.Equal(t, []int{3, 5, 7}, lines)
}
// Line numbers are metadata, not identity: a block's hash covers its structure
// and its content and nothing about where it sits. Moving a rule down the
// document must therefore leave every anchor pointing where it did, which is
// what makes fixing the line number above safe for stored comments.
func TestThematicBreakHashIgnoresItsPosition(t *testing.T) {
near := Segment([]byte("a\n\n---\n"))
far := Segment([]byte("a\n\nb\n\nc\n\n---\n"))
require.Equal(t, KindThematicBreak, near[len(near)-1].Kind)
require.Equal(t, KindThematicBreak, far[len(far)-1].Kind)
assert.Equal(t, near[len(near)-1].Hash, far[len(far)-1].Hash)
assert.NotEqual(t, near[len(near)-1].StartLine, far[len(far)-1].StartLine)
}
// A document that is nothing but "---" was admitted as frontmatter and then
// sliced at [4:] on three bytes. The review page renders whatever is on a
// branch, so this was a panic reachable from any one-line file.
func TestBareRuleDocumentIsNotFrontmatter(t *testing.T) {
got := Segment([]byte("---"))
require.Len(t, got, 1)
assert.Equal(t, KindThematicBreak, got[0].Kind)
assert.Equal(t, 1, got[0].StartLine)
// The neighbouring shapes that must keep working.
assert.NotPanics(t, func() { Segment([]byte("---\n")) })
assert.NotPanics(t, func() { Segment([]byte("--")) })
assert.NotPanics(t, func() { Segment([]byte("----")) })
fm := Segment([]byte("---\nid: X\n---\n\nbody\n"))
require.Len(t, fm, 2)
assert.Equal(t, KindFrontmatter, fm[0].Kind)
}