400 lines
14 KiB
Go
400 lines
14 KiB
Go
package helpers
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// These are the CommonMark/GFM line predicates the whole tier model rests on.
|
|
// They are tested directly because a misclassification here silently downgrades
|
|
// or upgrades a split tier, and an upgraded tier corrupts documents.
|
|
|
|
func TestCrossPlatformCoverageMarkdownScanLinePredicates(t *testing.T) {
|
|
t.Run("leadingIndent counts tabs as four columns", func(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
line string
|
|
want int
|
|
}{
|
|
{"none", 0}, {" two", 2}, {"\ttab", 4}, {" \ttab", 4}, {"\t\t", 8}, {" ", 4},
|
|
} {
|
|
if got := leadingIndent(tc.line); got != tc.want {
|
|
t.Errorf("leadingIndent(%q) = %d, want %d", tc.line, got, tc.want)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("fenceMarkerOf requires three or more markers", func(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
line string
|
|
ch byte
|
|
n int
|
|
ok bool
|
|
isOpe bool
|
|
}{
|
|
{"```", '`', 3, true, true},
|
|
{"```go", '`', 3, true, true},
|
|
{"````", '`', 4, true, true},
|
|
{"~~~", '~', 3, true, true},
|
|
{"~~~ yaml", '~', 3, true, true},
|
|
{"``", 0, 0, false, false}, // too short to fence
|
|
{"`code`", 0, 0, false, false}, // inline code
|
|
{"text", 0, 0, false, false}, // not a marker at all
|
|
{"", 0, 0, false, false}, // blank line inside a fence
|
|
{"```a`b", '`', 3, true, false}, // backtick in the info string
|
|
} {
|
|
ch, n, ok := fenceMarkerOf(tc.line)
|
|
if ch != tc.ch || n != tc.n || ok != tc.ok {
|
|
t.Errorf("fenceMarkerOf(%q) = %q,%d,%v want %q,%d,%v", tc.line, ch, n, ok, tc.ch, tc.n, tc.ok)
|
|
}
|
|
if got := isFenceOpen(tc.line); got != tc.isOpe {
|
|
t.Errorf("isFenceOpen(%q) = %v, want %v", tc.line, got, tc.isOpe)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("closingFenceIsBare rejects a trailing info string", func(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
line string
|
|
ch byte
|
|
n int
|
|
want bool
|
|
}{
|
|
{"```", '`', 3, true},
|
|
{"``` ", '`', 3, true},
|
|
{"`````", '`', 3, true}, // a longer run still closes
|
|
{"```go", '`', 3, false},
|
|
} {
|
|
if got := closingFenceIsBare(tc.line, tc.ch, tc.n); got != tc.want {
|
|
t.Errorf("closingFenceIsBare(%q) = %v, want %v", tc.line, got, tc.want)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("isATXHeading", func(t *testing.T) {
|
|
for line, want := range map[string]bool{
|
|
"# h": true, "###### h": true, "#": true, "#hash": false,
|
|
"####### too deep": false, "text": false, "": false, "#\tt": true,
|
|
} {
|
|
if got := isATXHeading(line); got != want {
|
|
t.Errorf("isATXHeading(%q) = %v, want %v", line, got, want)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("isRunOf and isThematicBreak", func(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
line string
|
|
runDash bool
|
|
breakAst bool
|
|
}{
|
|
{"---", true, false},
|
|
{"-", true, false},
|
|
{"", false, false},
|
|
{"-x-", false, false},
|
|
{"***", false, true},
|
|
{"* * *", false, true}, // interior spaces are allowed
|
|
{"**", false, false}, // fewer than three
|
|
{"*x*", false, false},
|
|
} {
|
|
if got := isRunOf(tc.line, '-'); got != tc.runDash {
|
|
t.Errorf("isRunOf(%q,'-') = %v, want %v", tc.line, got, tc.runDash)
|
|
}
|
|
if got := isThematicBreak(tc.line, '*'); got != tc.breakAst {
|
|
t.Errorf("isThematicBreak(%q,'*') = %v, want %v", tc.line, got, tc.breakAst)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("isTableDelimiterRow requires a pipe", func(t *testing.T) {
|
|
for line, want := range map[string]bool{
|
|
"|---|---|": true,
|
|
"---|---": true,
|
|
"| :-: | ---: |": true,
|
|
"---": false, // a thematic break or setext underline, never a table
|
|
"--": false,
|
|
"| a | b |": false, // a header row, not a delimiter
|
|
"|": false,
|
|
"| |": false, // empty cell, and no dash
|
|
"|---|x|": false, // one cell is not a run of dashes
|
|
"|---| |": false, // one cell is empty
|
|
"": false,
|
|
} {
|
|
if got := isTableDelimiterRow(line); got != want {
|
|
t.Errorf("isTableDelimiterRow(%q) = %v, want %v", line, got, want)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("tableCellCount", func(t *testing.T) {
|
|
for line, want := range map[string]int{
|
|
"| a | b |": 2, "a | b": 2, "| a |": 1, "no pipes": 0,
|
|
} {
|
|
if got := tableCellCount(line); got != want {
|
|
t.Errorf("tableCellCount(%q) = %d, want %d", line, got, want)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("listMarkerOf", func(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
line string
|
|
ordered bool
|
|
contentCol int
|
|
ok bool
|
|
}{
|
|
{"- item", false, 2, true},
|
|
{"+ item", false, 2, true},
|
|
{"* item", false, 2, true},
|
|
{"1. item", true, 3, true},
|
|
{"12) item", true, 4, true},
|
|
{"-", false, 1, true}, // a marker with no content
|
|
{"- wide", false, 2, true}, // more than four spaces resets to one
|
|
{" - nested", false, 4, true}, // indentation is included
|
|
{"-item", false, 0, false}, // no space after the marker
|
|
{"1.item", false, 0, false}, // no space after the number
|
|
{"1x item", false, 0, false}, // not a marker
|
|
{"text", false, 0, false},
|
|
{"", false, 0, false},
|
|
{" ", false, 0, false}, // whitespace only
|
|
} {
|
|
ordered, col, ok := listMarkerOf(tc.line)
|
|
if ordered != tc.ordered || ok != tc.ok || (ok && col != tc.contentCol) {
|
|
t.Errorf("listMarkerOf(%q) = %v,%d,%v want %v,%d,%v",
|
|
tc.line, ordered, col, ok, tc.ordered, tc.contentCol, tc.ok)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("listMarkerNumber", func(t *testing.T) {
|
|
for line, want := range map[string]int{
|
|
"1. a": 1, "7. a": 7, "12) a": 12, "- a": 0,
|
|
} {
|
|
if got := listMarkerNumber(line); got != want {
|
|
t.Errorf("listMarkerNumber(%q) = %d, want %d", line, got, want)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("htmlBlockKind classifies the seven types", func(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
line string
|
|
kind uint8
|
|
ok bool
|
|
}{
|
|
{"<script>", 1, true},
|
|
{"<PRE>", 1, true},
|
|
{"<style>", 1, true},
|
|
{"<textarea>", 1, true},
|
|
{"<!-- comment", 2, true},
|
|
{"<?php", 3, true},
|
|
{"<!DOCTYPE html>", 4, true},
|
|
{"<![CDATA[", 5, true},
|
|
{"<div>", 6, true},
|
|
{"</ul>", 6, true},
|
|
{"<custom-tag>", 7, true},
|
|
{"<1>", 0, false}, // a tag name may not start with a digit
|
|
{"<>", 0, false}, // no tag name at all
|
|
{"</>", 0, false}, // closing marker with no name
|
|
{"text", 0, false},
|
|
{"", 0, false},
|
|
} {
|
|
kind, ok := htmlBlockKind(tc.line)
|
|
if kind != tc.kind || ok != tc.ok {
|
|
t.Errorf("htmlBlockKind(%q) = %d,%v want %d,%v", tc.line, kind, ok, tc.kind, tc.ok)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("htmlCloser per type", func(t *testing.T) {
|
|
for kind, want := range map[uint8]string{2: "-->", 3: "?>", 4: ">", 5: "]]>", 1: "</script>"} {
|
|
if got := htmlCloser(kind); got != want {
|
|
t.Errorf("htmlCloser(%d) = %q, want %q", kind, got, want)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("isLinkReferenceDefinition", func(t *testing.T) {
|
|
for line, want := range map[string]bool{
|
|
"[ref]: https://example.com": true, "[a]: x": true,
|
|
"[]: x": false, "[ref] not a def": false, "text": false, "": false,
|
|
} {
|
|
if got := isLinkReferenceDefinition(line); got != want {
|
|
t.Errorf("isLinkReferenceDefinition(%q) = %v, want %v", line, got, want)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("trimTrailingWhitespaceEnd", func(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
content string
|
|
end int
|
|
want int
|
|
}{
|
|
{"abc\n\n", 5, 3}, {"abc", 3, 3}, {"\n\n\n", 3, 0}, {"a \t\n", 4, 1},
|
|
} {
|
|
if got := trimTrailingWhitespaceEnd(tc.content, tc.end); got != tc.want {
|
|
t.Errorf("trimTrailingWhitespaceEnd(%q,%d) = %d, want %d", tc.content, tc.end, got, tc.want)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestCrossPlatformCoverageMarkdownScanRegionGrouping(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
content string
|
|
// wantKinds lists the region kind of each region, in order.
|
|
wantKinds []regionKind
|
|
}{
|
|
{"unterminated fence swallows the tail", "before\n\n```go\nnever closed\nmore",
|
|
[]regionKind{regionParagraph, regionFence}},
|
|
{"indented fence is still a fence", " ```go\ncode\n ```",
|
|
[]regionKind{regionFence}},
|
|
{"quote ends at a heading", "> quoted\n# heading",
|
|
[]regionKind{regionQuote, regionLeaf}},
|
|
{"quote absorbs a lazy continuation", "> quoted\nlazy line\n\nafter",
|
|
[]regionKind{regionQuote, regionParagraph}},
|
|
{"setext beats a table delimiter", "heading\n---",
|
|
[]regionKind{regionSetext}},
|
|
{"delimiter row with a mismatched header is not a table", "para\n|---|---|---|",
|
|
[]regionKind{regionParagraph}},
|
|
{"table keeps rows that have no pipes", "| a | b |\n|---|---|\n| 1 | 2 |\nstray",
|
|
[]regionKind{regionTable}},
|
|
{"paragraph before a table is its own region", "intro\n| a | b |\n|---|---|\n| 1 | 2 |",
|
|
[]regionKind{regionParagraph, regionTable}},
|
|
{"list ends at an unindented paragraph", "- item\n\nplain paragraph",
|
|
[]regionKind{regionList, regionParagraph}},
|
|
{"list ends at an adjacent heading with no blank line", "- item\n# heading",
|
|
[]regionKind{regionList, regionLeaf}},
|
|
{"list ends at end of input after a blank line", "- item\n\n",
|
|
[]regionKind{regionList}},
|
|
{"list keeps an indented continuation across a blank line", "- item\n\n continued\n\nplain",
|
|
[]regionKind{regionList, regionParagraph}},
|
|
{"html type 6 ends at a blank line", "<div>\nbody\n\nafter",
|
|
[]regionKind{regionHTML, regionParagraph}},
|
|
{"html type 1 spans blank lines", "<script>\n\nstill script\n</script>\n\nafter",
|
|
[]regionKind{regionHTML, regionParagraph}},
|
|
// Each type-1 tag must end at its OWN closer, not only </script>; a
|
|
// mis-terminated <pre>/<style>/<textarea> would swallow everything after it.
|
|
{"html type 1 pre ends at its own closer", "<pre>\ncode\n</pre>\n\nafter",
|
|
[]regionKind{regionHTML, regionParagraph}},
|
|
{"html type 1 style ends at its own closer", "<style>\n.x{}\n</style>\n\nafter",
|
|
[]regionKind{regionHTML, regionParagraph}},
|
|
{"html type 1 textarea ends at its own closer", "<textarea>\nhi\n</textarea>\n\nafter",
|
|
[]regionKind{regionHTML, regionParagraph}},
|
|
{"html type 2 comment ends at its own terminator", "<!-- note -->\n\nafter",
|
|
[]regionKind{regionHTML, regionParagraph}},
|
|
{"html type 2 comment without a terminator runs to end of input", "<!--\nunclosed comment",
|
|
[]regionKind{regionHTML}},
|
|
{"indented code spans a blank line", " one\n\n two\n\nplain",
|
|
[]regionKind{regionIndentedCode, regionParagraph}},
|
|
{"link reference definition is a leaf", "[ref]: https://example.com\n\npara",
|
|
[]regionKind{regionLeaf, regionParagraph}},
|
|
{"dash rule with no paragraph above is a leaf", "---\n\npara",
|
|
[]regionKind{regionLeaf, regionParagraph}},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
scan := scanMarkdownStructure(tc.content)
|
|
got := make([]regionKind, 0, len(scan.regions))
|
|
for _, r := range scan.regions {
|
|
got = append(got, r.kind)
|
|
}
|
|
if len(got) != len(tc.wantKinds) {
|
|
t.Fatalf("regions = %v, want %v", got, tc.wantKinds)
|
|
}
|
|
for i := range got {
|
|
if got[i] != tc.wantKinds[i] {
|
|
t.Errorf("region %d = %d, want %d (all: %v)", i, got[i], tc.wantKinds[i], got)
|
|
}
|
|
}
|
|
// Every line must belong to exactly the region that claims it, and
|
|
// the regions must tile the non-blank lines in order.
|
|
for i, r := range scan.regions {
|
|
for k := r.firstLine; k <= r.lastLine; k++ {
|
|
if scan.lines[k].region != int32(i) {
|
|
t.Errorf("line %d claims region %d, want %d", k, scan.lines[k].region, i)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageMarkdownScanHTMLRepairDetection(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
content string
|
|
repair bool
|
|
closer string
|
|
}{
|
|
{"pre is repairable", "<pre>\na\nb\n</pre>", true, "</pre>"},
|
|
{"style is repairable", "<style>\n.a{}\n.b{}\n</style>", true, "</style>"},
|
|
{"textarea is repairable", "<textarea>\na\nb\n</textarea>", true, "</textarea>"},
|
|
{"multi-line opening tag is repairable", "<pre\n class=\"c\">\na\nb\n</pre>", true, "</pre>"},
|
|
{"script is never repaired", "<script>\na\nb\n</script>", false, ""},
|
|
{"div is not repairable", "<div>\na\nb\n</div>", false, ""},
|
|
{"single-line pre has no interior", "<pre>x</pre>\n\ntail", false, ""},
|
|
{"unterminated pre is not repaired", "<pre>\na\nb", false, ""},
|
|
{"pre without a closing '>' is not repaired", "<pre\nno gt ever here", false, ""},
|
|
{"<presentation> is not <pre>", "<presentation>\na\n</presentation>", false, ""},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
scan := scanMarkdownStructure(tc.content)
|
|
var html *region
|
|
for i := range scan.regions {
|
|
if scan.regions[i].kind == regionHTML {
|
|
html = &scan.regions[i]
|
|
break
|
|
}
|
|
}
|
|
if html == nil {
|
|
t.Fatalf("no HTML region in %q (regions=%d)", tc.content, len(scan.regions))
|
|
}
|
|
if html.htmlRepair != tc.repair {
|
|
t.Errorf("htmlRepair = %v, want %v", html.htmlRepair, tc.repair)
|
|
}
|
|
if html.htmlRepair && html.htmlCloser != tc.closer {
|
|
t.Errorf("htmlCloser = %q, want %q", html.htmlCloser, tc.closer)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageMarkdownScanInterruptsParagraph(t *testing.T) {
|
|
// A construct that cannot interrupt a paragraph must not produce a safe
|
|
// boundary, because splitting there changes how the text above it parses.
|
|
for _, tc := range []struct {
|
|
name string
|
|
content string
|
|
limit int
|
|
wantSafe bool
|
|
wantDegrad string
|
|
}{
|
|
{"ordered list starting at one interrupts", "para text\n1. item", 10, true, ""},
|
|
// `7.` cannot interrupt a paragraph, so the marker line is paragraph
|
|
// text and the boundary is a paragraph split, not a block boundary.
|
|
{"ordered list starting past one does not", "para text\n7. item", 10, false, "paragraph_split"},
|
|
{"unordered list interrupts", "para text\n- item", 10, true, ""},
|
|
{"heading interrupts", "para text\n## head", 10, true, ""},
|
|
// The limit must leave room for the whole fence in the second chunk;
|
|
// otherwise the fence itself needs splitting and that is a separate case.
|
|
{"fence interrupts", "para text\n```go\nx\n```", 12, true, ""},
|
|
{"quote interrupts", "para text\n> quoted", 10, true, ""},
|
|
{"html type 6 interrupts", "para text\n<div>x</div>", 13, true, ""},
|
|
{"asterisk rule interrupts", "para text\n***", 10, true, ""},
|
|
{"dash rule does not interrupt", "para text\n---", 10, false, ""},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
plan := SplitMarkdownForAppend(tc.content, tc.limit)
|
|
if tc.wantSafe && plan.Degraded() {
|
|
t.Errorf("expected a clean split, got %v", degradationKinds(plan))
|
|
}
|
|
if tc.wantDegrad != "" {
|
|
if got := strings.Join(degradationKinds(plan), ","); !strings.Contains(got, tc.wantDegrad) {
|
|
t.Errorf("degradations = %q, want to contain %q", got, tc.wantDegrad)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|