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