package web
import (
"fmt"
"math/rand"
"regexp"
"strings"
"testing"
"sourcecraft.dev/bigbes/sr-ht-spec/core"
"sourcecraft.dev/bigbes/sr-ht-spec/prosediff"
)
// These tests read the row model directly rather than the markup it becomes.
// The thing that can be quietly wrong here is arithmetic — which line number
// goes in which gutter track — and a test that matched HTML would be asserting
// the arithmetic through a layer that can also change for cosmetic reasons.
// stubInfo gives every change its own commentable block. The row model never
// looks inside an anchor, only at whether there is one, so these are simply
// distinct; threads names the changes (by position) that carry a comment.
func stubInfo(threads map[int]bool, owner bool) func(prosediff.BlockChange) blockInfo {
n := -1
return func(c prosediff.BlockChange) blockInfo {
n++
// A move-out marker is the one change the renderer offers no anchor for.
if c.Kind == prosediff.ChangeMoveOut {
return blockInfo{}
}
has := threads[n]
return blockInfo{
Anchor: core.CommentAnchor{DocID: "D", Index: n, BlockHash: fmt.Sprint(n)},
Key: blockKey{core.SideNew, n},
Commentable: true,
HasThreads: has,
Notes: has || owner,
}
}
}
// rowsOf builds the row model of two revisions, as a reviewer with no comments
// and no compose form sees it.
func rowsOf(oldSrc, newSrc string) []diffRow {
d := prosediff.Compare([]byte(oldSrc), []byte(newSrc))
return buildRows(d.Changes, stubInfo(nil, false))
}
// modifyRowsOf isolates the rows of the single modified block in a diff, so a
// test about line attribution is not also a test about what the aligner paired.
func modifyRowsOf(t *testing.T, oldSrc, newSrc string) []diffRow {
t.Helper()
for _, c := range prosediff.Compare([]byte(oldSrc), []byte(newSrc)).Changes {
if c.Kind == prosediff.ChangeModify {
return buildRows([]prosediff.BlockChange{c}, stubInfo(nil, false))
}
}
t.Fatalf("no modified block in the diff of:\n%s\n---\n%s", oldSrc, newSrc)
return nil
}
// summarize renders rows as "<kind> <old>/<new> <text>", with an interpunct for
// a track this row states no number for and inline brackets for the word marks,
// so an expectation reads as what the reviewer would see in the gutter.
func summarize(rows []diffRow) []string {
out := make([]string, len(rows))
for i, row := range rows {
out[i] = fmt.Sprintf("%s %s/%s %s", row.Kind,
track(row.OldNum, row.OldEnd), track(row.NewNum, row.NewEnd), rowText(row))
}
return out
}
func track(n, end int) string {
switch {
case n == 0:
return "·"
case end > n:
return fmt.Sprintf("%d–%d", n, end)
default:
return fmt.Sprint(n)
}
}
func rowText(row diffRow) string {
if row.Note != "" {
return row.Note
}
var b strings.Builder
for i, s := range row.Spans {
if s.Space && i > 0 {
b.WriteByte(' ')
}
switch s.Op {
case prosediff.OpDelete:
b.WriteString("[-" + s.Text + "-]")
case prosediff.OpInsert:
b.WriteString("{+" + s.Text + "+}")
default:
b.WriteString(s.Text)
}
}
return b.String()
}
func checkRows(t *testing.T, got []diffRow, want []string) {
t.Helper()
summary := summarize(got)
if len(summary) != len(want) {
t.Fatalf("rows =\n %s\nwant\n %s", strings.Join(summary, "\n "), strings.Join(want, "\n "))
}
for i := range want {
if summary[i] != want[i] {
t.Errorf("row %d = %q, want %q", i, summary[i], want[i])
}
}
}
// An inserted block numbers the new revision and leaves the old track empty:
// those lines exist in one revision only, and a number in the other track would
// name a line that was never there.
func TestInsertedBlockNumbersOnlyTheNewSide(t *testing.T) {
const old = "# H\n\nkept.\n"
const nw = "# H\n\nkept.\n\nfirst added line\nsecond added line\n"
var ins []diffRow
for _, row := range rowsOf(old, nw) {
if row.Kind == rowInsert {
ins = append(ins, row)
}
}
checkRows(t, ins, []string{
"ins ·/5 first added line",
"ins ·/6 second added line",
})
}
// A deleted block is the mirror image, and is numbered against the base
// revision because that is the only revision it is in.
func TestDeletedBlockNumbersOnlyTheOldSide(t *testing.T) {
const old = "# H\n\nkept.\n\ndoomed line one\ndoomed line two\n"
const nw = "# H\n\nkept.\n"
var del []diffRow
for _, row := range rowsOf(old, nw) {
if row.Kind == rowDelete {
del = append(del, row)
}
}
checkRows(t, del, []string{
"del 5/· doomed line one",
"del 6/· doomed line two",
})
}
// An unchanged block occupying the same lines in both revisions states both
// numbers: that is the ordinary context row, and the reviewer can check either
// against the file.
func TestEqualBlockNumbersBothSidesWhenTheLineCountsAgree(t *testing.T) {
const src = "# H\n\nfirst context line\nsecond context line\n"
rows := buildRows(
prosediff.Compare([]byte(src+"\ntail.\n"), []byte(src+"\ntail, edited.\n")).Changes,
stubInfo(nil, false))
var eq []diffRow
for _, row := range rows {
if row.Kind == rowEqual && row.Block.Context {
eq = append(eq, row)
}
}
// The heading row carries "H" and not "# H": prosediff records a heading's
// text without its markers, and a row shows the block's own lines.
checkRows(t, eq, []string{
"eq 1/1 H",
"eq 3/3 first context line",
"eq 4/4 second context line",
})
}
// A block that was only rewrapped is unchanged prose sitting on a different
// number of lines. The new side is numbered and the old side is left blank,
// because there is no line-to-line correspondence to state and interpolating
// one would be inventing evidence.
func TestRewrappedEqualBlockLeavesTheOldTrackEmpty(t *testing.T) {
const old = "# H\n\nthe quick brown fox\njumps over the lazy dog\n\ntail.\n"
const nw = "# H\n\nthe quick brown fox jumps\nover\nthe lazy dog\n\ntail, edited.\n"
var eq []diffRow
for _, row := range rowsOf(old, nw) {
if row.Kind == rowEqual && row.Block.Context && row.OldNum != 1 {
eq = append(eq, row)
}
}
checkRows(t, eq, []string{
"eq ·/3 the quick brown fox jumps",
"eq ·/4 over",
"eq ·/5 the lazy dog",
})
}
// The same rule when the rewrap happens to preserve the line count — the case
// equal counts were once taken as proof of a 1:1 correspondence.
//
// "alpha beta / gamma delta" becoming "alpha / beta gamma delta" is two lines
// either way and unchanged prose either way, but nothing on new line 2 was on
// old line 2. Numbering it 2 offered the reviewer a drag that would have
// anchored a comment to text that revision never contained.
func TestRewrapKeepingTheLineCountStillLeavesTheOldTrackEmpty(t *testing.T) {
const old = "alpha beta\ngamma delta\n\nzzz trigger\n"
const nw = "alpha\nbeta gamma delta\n\nzzz triggered\n"
var eq []diffRow
for _, row := range rowsOf(old, nw) {
if row.Kind == rowEqual && row.Block.Context {
eq = append(eq, row)
}
}
checkRows(t, eq, []string{
"eq ·/1 alpha",
"eq ·/2 beta gamma delta",
})
}
// Pairing is decided per row, not per block, so the lines a rewrap did not
// touch keep their old numbers. Blanking the whole block whenever any line
// moved would be honest but needlessly lossy: the old track is where a reviewer
// looks to find the text in the base revision.
func TestUnmovedLinesOfARewrappedBlockKeepTheirOldNumbers(t *testing.T) {
const old = "one two\nthree four\nfive six seven\n\nzzz trigger\n"
const nw = "one two\nthree four\nfive six\nseven\n\nzzz triggered\n"
var eq []diffRow
for _, row := range rowsOf(old, nw) {
if row.Kind == rowEqual && row.Block.Context {
eq = append(eq, row)
}
}
checkRows(t, eq, []string{
"eq 1/1 one two",
"eq 2/2 three four",
"eq ·/3 five six",
"eq ·/4 seven",
})
}
// A modified prose block similar enough to follow is spread back over its own
// lines: the change is a row of its own on each side, numbered from that side,
// and the lines around it stay context rows carrying both numbers.
func TestModifiedProseGoesPerLine(t *testing.T) {
const old = "# H\n\nalpha beta gamma\ndelta epsilon zeta\neta theta iota\n"
const nw = "# H\n\nalpha beta gamma\ndelta CHANGED zeta\neta theta iota\n"
checkRows(t, modifyRowsOf(t, old, nw), []string{
"eq 3/3 alpha beta gamma",
"del 4/· delta [-epsilon-] zeta",
"ins ·/4 delta {+CHANGED+} zeta",
"eq 5/5 eta theta iota",
})
}
// A block rewritten past the similarity threshold falls back to one row per
// side, labelled by the line range it covers. Per-line numbers here would be a
// guess, and the design's rule is that a range beats a number nobody can check.
func TestShreddedBlockFallsBackToRegionRows(t *testing.T) {
const old = "# H\n\nThe committee approved the annual budget\nafter a long and contentious debate that\nlasted well into the evening.\n"
const nw = "# H\n\nThe committee rejected the annual budget\nafter a brief and quiet discussion that\nended early in the afternoon.\n"
rows := modifyRowsOf(t, old, nw)
for _, row := range rows {
if !row.Region {
t.Fatalf("a shredded block rendered per line:\n %s", strings.Join(summarize(rows), "\n "))
}
}
if len(rows) != 2 {
t.Fatalf("region fallback produced %d rows, want 2:\n %s", len(rows), strings.Join(summarize(rows), "\n "))
}
if got := track(rows[0].OldNum, rows[0].OldEnd); got != "3–5" {
t.Errorf("old region covers %q, want the whole block's range 3–5", got)
}
if rows[0].NewNum != 0 || rows[1].OldNum != 0 {
t.Errorf("a region row numbered the side it does not describe: %v", summarize(rows))
}
if got := track(rows[1].NewNum, rows[1].NewEnd); got != "3–5" {
t.Errorf("new region covers %q, want 3–5", got)
}
// Each side keeps its own words and its own marks: two readable paragraphs.
if got := rowText(rows[0]); !strings.Contains(got, "[-approved-]") || strings.Contains(got, "{+") {
t.Errorf("old region = %q, want the deletions and no insertions", got)
}
if got := rowText(rows[1]); !strings.Contains(got, "{+rejected+}") || strings.Contains(got, "[-") {
t.Errorf("new region = %q, want the insertions and no deletions", got)
}
// The space that belonged to the deletion this insertion replaced has to
// survive dropping it, or the row runs two words together.
if got := rowText(rows[1]); !strings.Contains(got, "committee {+rejected+}") {
t.Errorf("new region = %q, want a separator before the replacement", got)
}
}
// A code fence has a line-oriented script already, so its rows are read off it
// rather than recovered: equal lines carry both numbers, and the two counters
// walk their own revisions.
func TestModifiedCodeFenceFollowsTheLineScript(t *testing.T) {
const old = "# H\n\n```go\na := 1\nb := 2\nc := 3\n```\n"
const nw = "# H\n\n```go\na := 1\nb := 20\nc := 3\n```\n"
// The fence delimiters are not part of the block, so the rows start at the
// first line of code — line 4 of the document.
rows := modifyRowsOf(t, old, nw)
checkRows(t, rows, []string{
"eq 4/4 a := 1",
"del 5/· b := 2",
"ins ·/5 b := 20",
"eq 6/6 c := 3",
})
for _, row := range rows {
if !row.Block.Mono {
t.Fatalf("a code fence row is not marked monospaced: %+v", row)
}
}
}
// A move renders as a marker where the block was and the block itself where it
// now is. The marker at the old position is not commentable: the text is on the
// page once, and anchoring it twice would give one paragraph two places to be
// argued about.
func TestMovedBlockPair(t *testing.T) {
old := &prosediff.Block{
Kind: prosediff.KindParagraph, Text: "the travelling paragraph",
Lines: []string{"the travelling paragraph"}, StartLine: 3, EndLine: 3,
}
nw := &prosediff.Block{
Kind: prosediff.KindParagraph, Text: "the travelling paragraph",
Lines: []string{"the travelling paragraph"}, StartLine: 12, EndLine: 12,
}
rows := buildRows([]prosediff.BlockChange{
{Kind: prosediff.ChangeMoveOut, Old: old, New: nw},
{Kind: prosediff.ChangeMoveIn, Old: old, New: nw},
}, stubInfo(nil, false))
checkRows(t, rows, []string{
"move 3/· paragraph moved away (now line 12)",
"move ·/· paragraph moved here (was line 3)",
"move ·/12 the travelling paragraph",
})
if rows[0].Block.Commentable {
t.Errorf("the move-out marker offers an anchor; it points at text rendered elsewhere")
}
if !rows[1].Block.Commentable || !rows[1].Start {
t.Errorf("the move-in is not an anchorable block starting at its marker row")
}
}
// Every block starts exactly one row, and its notes row closes it: a comment
// belongs under the lines it is about, and the id a link scrolls to belongs on
// the first of them and nowhere else.
func TestEachBlockStartsOnceAndItsNotesRowComesLast(t *testing.T) {
const old = "# H\n\ncontext.\n\nold wording.\n"
const nw = "# H\n\ncontext.\n\nnew wording.\n"
rows := buildRows(prosediff.Compare([]byte(old), []byte(nw)).Changes, stubInfo(nil, true))
starts, notes := 0, 0
for i, row := range rows {
if row.Start {
starts++
if row.Kind == rowNotes {
t.Errorf("row %d: a notes row opened a block", i)
}
}
if row.Kind == rowNotes {
notes++
if i+1 < len(rows) && rows[i+1].Block == row.Block {
t.Errorf("row %d: the notes row is not the block's last row", i)
}
}
}
// Heading, context paragraph, modified paragraph.
if starts != 3 || notes != 3 {
t.Errorf("%d block starts and %d notes rows, want 3 and 3", starts, notes)
}
}
// contextChanges is n unchanged one-line blocks, which is the shape the folder
// is about.
func contextChanges(n int) []prosediff.BlockChange {
out := make([]prosediff.BlockChange, n)
for i := range out {
blk := &prosediff.Block{
Kind: prosediff.KindParagraph,
Text: fmt.Sprintf("context line %d", i+1),
Lines: []string{fmt.Sprintf("context line %d", i+1)},
StartLine: i + 1, EndLine: i + 1,
}
out[i] = prosediff.BlockChange{Kind: prosediff.ChangeEqual, Old: blk, New: blk}
}
return out
}
func foldGroups(groups []rowGroup) []rowGroup {
var out []rowGroup
for _, g := range groups {
if g.Fold {
out = append(out, g)
}
}
return out
}
// checkNotesFollowTheirBlock asserts the rule a fold must never break: a
// block's notes row is hidden exactly when every line of that block is. A
// compose form offered for text nobody can see is a control on nothing, and one
// hidden away from text that is on screen makes a commentable block look
// uncommentable.
func checkNotesFollowTheirBlock(t *testing.T, groups []rowGroup) {
t.Helper()
visible := make(map[*rowBlock]bool)
for _, g := range groups {
for _, row := range g.Rows {
if row.Kind != rowNotes && !row.Folded {
visible[row.Block] = true
}
}
}
for _, g := range groups {
for _, row := range g.Rows {
if row.Kind != rowNotes {
continue
}
if row.Folded == visible[row.Block] {
t.Errorf("block %q: notes row folded=%v, block has visible lines=%v",
row.Block.Anchor.BlockHash, row.Folded, visible[row.Block])
}
}
}
}
// checkFoldGroupsAreWhollyHidden asserts the other half: everything inside a
// fold's <tbody> is hidden by it. A visible row in there renders between the
// fold's toggle and the next line — which is exactly how the orphaned compose
// forms appeared on the page.
func checkFoldGroupsAreWhollyHidden(t *testing.T, groups []rowGroup) {
t.Helper()
for _, g := range groups {
if !g.Fold {
continue
}
for _, row := range g.Rows {
if !row.Folded {
t.Errorf("a %s row sits inside a fold group unhidden: %q", row.Kind, rowText(row))
}
}
}
}
// paragraphChanges is n unchanged blocks of lines lines each, so a test can put
// a block boundary where it wants one relative to the fold's edges.
func paragraphChanges(n, lines int) []prosediff.BlockChange {
out := make([]prosediff.BlockChange, n)
at := 1
for i := range out {
blk := &prosediff.Block{
Kind: prosediff.KindParagraph, StartLine: at, EndLine: at + lines - 1,
}
for j := 0; j < lines; j++ {
blk.Lines = append(blk.Lines, fmt.Sprintf("block %d line %d", i+1, j+1))
}
blk.Text = strings.Join(blk.Lines, "\n")
out[i] = prosediff.BlockChange{Kind: prosediff.ChangeEqual, Old: blk, New: blk}
at += lines + 1
}
return out
}
// A long run of context collapses and a short one does not. Six is the run
// length the markup contract names, but keeping two rows at each end would
// leave a fold hiding two lines behind a click that costs one row to draw, so
// the second gate — at least three hidden lines — is what actually decides.
func TestContextRunFoldsOnlyWhenItSavesSomething(t *testing.T) {
for _, tc := range []struct {
run int
hidden int // 0 for "does not fold"
}{
{run: 5, hidden: 0},
{run: 6, hidden: 0},
{run: 7, hidden: 3},
{run: 10, hidden: 6},
} {
rows := buildRows(contextChanges(tc.run), stubInfo(nil, false))
groups := groupRows(rows)
folds := foldGroups(groups)
if tc.hidden == 0 {
if len(folds) != 0 {
t.Errorf("a run of %d folded; want it left alone", tc.run)
}
continue
}
if len(folds) != 1 {
t.Fatalf("a run of %d produced %d folds, want 1", tc.run, len(folds))
}
if folds[0].Hidden != tc.hidden || len(folds[0].Rows) != tc.hidden {
t.Errorf("a run of %d hides %d rows (label says %d), want %d",
tc.run, len(folds[0].Rows), folds[0].Hidden, tc.hidden)
}
checkFoldGroupsAreWhollyHidden(t, groups)
checkNotesFollowTheirBlock(t, groups)
// Nothing is dropped on the way into a group, and the order is the
// document's: a fold hides rows, it does not remove them.
var back []diffRow
for _, g := range groups {
back = append(back, g.Rows...)
}
if len(back) != len(rows) {
t.Errorf("grouping a run of %d yielded %d rows, want %d", tc.run, len(back), len(rows))
}
for i := range back {
if rowText(back[i]) != rowText(rows[i]) {
t.Fatalf("grouping reordered the rows at %d", i)
}
}
}
}
// A block someone has commented on is not context any more, so it is never
// hidden — and it breaks the run around it rather than being skipped over,
// because a fold that stepped over it would hide the lines the comment is
// about.
func TestACommentedBlockKeepsItsRunOpen(t *testing.T) {
rows := buildRows(contextChanges(8), stubInfo(map[int]bool{3: true}, false))
groups := groupRows(rows)
if folds := foldGroups(groups); len(folds) != 0 {
t.Fatalf("a run split by a commented block still folded: %+v", folds)
}
for _, g := range groups {
for _, row := range g.Rows {
if row.Folded {
t.Errorf("a row was hidden anyway: %+v", row)
}
}
}
}
// The compose form the owner sees hangs off every block, including a context
// one, and it folds with the lines it belongs to rather than breaking the run.
// The fold's label counts lines, though: a notes row is not a line.
func TestNotesRowsFoldWithTheirBlockAndAreNotCountedAsLines(t *testing.T) {
rows := buildRows(contextChanges(8), stubInfo(nil, true))
groups := groupRows(rows)
folds := foldGroups(groups)
if len(folds) != 1 {
t.Fatalf("the owner's compose forms broke the run: %d folds, want 1", len(folds))
}
if folds[0].Hidden != 4 {
t.Errorf("the fold says it hides %d lines, want 4", folds[0].Hidden)
}
lines, notes := 0, 0
for _, row := range folds[0].Rows {
if row.Kind == rowNotes {
notes++
} else {
lines++
}
}
if lines != 4 || notes != 4 {
t.Errorf("the fold holds %d lines and %d notes rows, want 4 and 4", lines, notes)
}
// Each of those four blocks is hidden whole: its line and its compose form.
checkFoldGroupsAreWhollyHidden(t, groups)
checkNotesFollowTheirBlock(t, groups)
}
// A fold whose opening edge lands inside a block gives that block back rather
// than hiding the compose form belonging to lines still on screen. The fold
// starts at the next block instead, hiding less and staying honest about what
// can be commented on.
func TestFoldOpensSoThatNoVisibleBlockLosesItsComposer(t *testing.T) {
// Two six-line paragraphs: keeping two lines at each end puts the raw
// opening edge in the middle of the first one, three rows above its notes row.
rows := buildRows(paragraphChanges(2, 6), stubInfo(nil, true))
groups := groupRows(rows)
folds := foldGroups(groups)
if len(folds) != 1 {
t.Fatalf("got %d folds, want 1", len(folds))
}
checkFoldGroupsAreWhollyHidden(t, groups)
checkNotesFollowTheirBlock(t, groups)
// The fold gave back the whole first paragraph and hides the second's first
// four lines, which is where a block boundary actually falls.
if got := summarize(folds[0].Rows); len(got) != 4 || got[0] != "eq 8/8 block 2 line 1" {
t.Errorf("the fold hides %v, want the second block's first four lines", got)
}
if folds[0].Hidden != 4 {
t.Errorf("the fold says it hides %d lines, want 4", folds[0].Hidden)
}
for _, g := range groups {
for _, row := range g.Rows {
if row.Folded && strings.HasPrefix(rowText(row), "block 1") {
t.Errorf("a line of the first block was hidden: %q", rowText(row))
}
}
}
}
// The rule holds when the last block of a run is only partly hidden too: its
// trailing lines and its composer stay together below the fold.
func TestFoldClosingEdgeKeepsAPartlyVisibleBlocksComposer(t *testing.T) {
rows := buildRows(paragraphChanges(3, 4), stubInfo(nil, true))
groups := groupRows(rows)
if len(foldGroups(groups)) != 1 {
t.Fatalf("got %d folds, want 1", len(foldGroups(groups)))
}
checkFoldGroupsAreWhollyHidden(t, groups)
checkNotesFollowTheirBlock(t, groups)
}
// ---- property test --------------------------------------------------------
//
// Everything above is a case someone thought of. The defect this section
// exists for was not: pairing a rewrapped block's lines by position was
// reviewed, unit-tested and wrong, because the test that covered it chose an
// example where the line counts differed. Line attribution is exactly the kind
// of invariant that hand-written cases bless and generated ones break, so the
// generator stays.
//
// The invariant it checks is the one the whole gutter rests on: whatever text a
// row renders is text that actually sits on the source lines that row's numbers
// name. Every generated line carries a nonce word found nowhere else, so a row
// that names a neighbouring line cannot pass by resembling it.
// propCases is the budget per seed. Small documents diff in microseconds, so a
// few thousand cases cost less than the package's HTTP tests and run on every
// `go test ./...` rather than on a fuzzing run nobody remembers to start.
const propCases = 700
// genBlock is one generated block: the kind decides how its lines are written
// into the document, and lines are the words a reader would see.
type genBlock struct {
kind string // "heading", "para", "rule", "code"
lines []string
}
// source writes a document, one blank line between blocks — always, because
// "text" directly above "---" is a setext heading rather than a paragraph and a
// rule, and the generator is not trying to test goldmark.
func source(blocks []genBlock) string {
var b strings.Builder
for i, blk := range blocks {
if i > 0 {
b.WriteString("\n")
}
switch blk.kind {
case "heading":
b.WriteString("# " + blk.lines[0] + "\n")
case "rule":
b.WriteString("---\n")
case "code":
b.WriteString("```\n")
for _, ln := range blk.lines {
b.WriteString(ln + "\n")
}
b.WriteString("```\n")
default:
for _, ln := range blk.lines {
b.WriteString(ln + "\n")
}
}
}
return b.String()
}
// nonces hands out a word that appears exactly once in the pair of documents,
// which is what turns "the row renders text from the line it names" into a test
// an off-by-one cannot survive.
type nonces struct{ n int }
func (g *nonces) word() string {
g.n++
return fmt.Sprintf("w%d", g.n)
}
func (g *nonces) line(rng *rand.Rand) string {
words := []string{g.word()}
for i := rng.Intn(4); i > 0; i-- {
words = append(words, g.word())
}
rng.Shuffle(len(words), func(i, j int) { words[i], words[j] = words[j], words[i] })
return strings.Join(words, " ")
}
func (g *nonces) block(rng *rand.Rand) genBlock {
switch rng.Intn(10) {
case 0:
return genBlock{kind: "heading", lines: []string{g.line(rng)}}
case 1:
return genBlock{kind: "rule", lines: []string{"---"}}
case 2, 3:
blk := genBlock{kind: "code"}
for i := rng.Intn(3) + 1; i > 0; i-- {
blk.lines = append(blk.lines, g.line(rng))
}
return blk
default:
blk := genBlock{kind: "para"}
for i := rng.Intn(4) + 1; i > 0; i-- {
blk.lines = append(blk.lines, g.line(rng))
}
return blk
}
}
func (g *nonces) document(rng *rand.Rand) []genBlock {
var out []genBlock
for i := rng.Intn(5) + 1; i > 0; i-- {
out = append(out, g.block(rng))
}
return out
}
// mutate is the proposal an agent might have pushed: a rewrap that the differ
// is designed not to see, a word changed, a block added, removed or moved.
func (g *nonces) mutate(rng *rand.Rand, in []genBlock) []genBlock {
out := append([]genBlock(nil), in...)
for i := range out {
out[i].lines = append([]string(nil), out[i].lines...)
}
for n := rng.Intn(3) + 1; n > 0 && len(out) > 0; n-- {
at := rng.Intn(len(out))
switch rng.Intn(6) {
case 0: // rewrap: the same words, different line breaks
if out[at].kind != "para" {
continue
}
words := strings.Fields(strings.Join(out[at].lines, " "))
var lines []string
for len(words) > 0 {
take := rng.Intn(len(words)) + 1
lines = append(lines, strings.Join(words[:take], " "))
words = words[take:]
}
out[at].lines = lines
case 1: // one word replaced, in place
i := rng.Intn(len(out[at].lines))
words := strings.Fields(out[at].lines[i])
if len(words) == 0 || out[at].kind == "rule" {
continue
}
words[rng.Intn(len(words))] = g.word()
out[at].lines[i] = strings.Join(words, " ")
case 2: // a block appears
blk := g.block(rng)
out = append(out[:at], append([]genBlock{blk}, out[at:]...)...)
case 3: // a block goes away
out = append(out[:at], out[at+1:]...)
case 4: // a block moves, which is what produces move markers
if len(out) < 2 {
continue
}
blk := out[at]
out = append(out[:at], out[at+1:]...)
to := rng.Intn(len(out) + 1)
out = append(out[:to], append([]genBlock{blk}, out[to:]...)...)
case 5: // a line appears inside a block
if out[at].kind == "rule" || out[at].kind == "heading" {
continue
}
i := rng.Intn(len(out[at].lines) + 1)
out[at].lines = append(out[at].lines[:i],
append([]string{g.line(rng)}, out[at].lines[i:]...)...)
}
}
return out
}
// TestRowNumbersNameTheLinesTheyRender is the property. It builds documents and
// edits from fixed seeds — reproducible, so a failure is a case anyone can
// replay — and asserts of every rendered row that its text really is on the
// lines its gutter claims.
func TestRowNumbersNameTheLinesTheyRender(t *testing.T) {
for _, seed := range []int64{1, 7, 1979, 20260805} {
rng := rand.New(rand.NewSource(seed))
for i := 0; i < propCases; i++ {
g := &nonces{}
base := source(g.document(rng))
proposed := source(g.mutate(rng, parseBack(base)))
d := prosediff.Compare([]byte(base), []byte(proposed))
rows := buildRows(d.Changes, stubInfo(nil, false))
checkRowsNameTheirText(t, rows, base, proposed, seed, i)
if t.Failed() {
return // one reproduction is enough; the rest would be noise
}
}
}
}
// parseBack recovers the generated blocks from a rendered document, so mutate
// works on the same structure the differ will see rather than on a shape only
// the generator knows about.
func parseBack(src string) []genBlock {
var out []genBlock
for _, chunk := range strings.Split(strings.TrimRight(src, "\n"), "\n\n") {
lines := strings.Split(chunk, "\n")
switch {
case strings.HasPrefix(lines[0], "# "):
out = append(out, genBlock{kind: "heading", lines: []string{strings.TrimPrefix(lines[0], "# ")}})
case lines[0] == "---":
out = append(out, genBlock{kind: "rule", lines: []string{"---"}})
case lines[0] == "```":
out = append(out, genBlock{kind: "code", lines: lines[1 : len(lines)-1]})
default:
out = append(out, genBlock{kind: "para", lines: lines})
}
}
return out
}
func checkRowsNameTheirText(t *testing.T, rows []diffRow, base, proposed string, seed int64, iter int) {
t.Helper()
baseLines := strings.Split(base, "\n")
propLines := strings.Split(proposed, "\n")
for _, row := range rows {
// A notes row holds no document text, and a move marker's text is the
// renderer's own sentence rather than the document's.
if row.Kind == rowNotes || row.Note != "" {
continue
}
got := noncesOf(rowPlainText(row))
check := func(side string, lines []string, from, to int) {
if from == 0 {
return
}
if to < from {
to = from
}
if from < 1 || to > len(lines) {
t.Errorf("seed %d case %d: row names %s lines %d–%d of a %d-line revision\nbase:\n%s\nproposed:\n%s",
seed, iter, side, from, to, len(lines), base, proposed)
return
}
want := noncesOf(strings.Join(lines[from-1:to], " "))
if !inOrderSubsequence(got, want) {
t.Errorf("seed %d case %d: row %q claims %s line(s) %d–%d, which hold %q\nbase:\n%s\nproposed:\n%s",
seed, iter, rowText(row), side, from, to, strings.Join(lines[from-1:to], " | "), base, proposed)
}
}
check("old", baseLines, row.OldNum, row.OldEnd)
check("new", propLines, row.NewNum, row.NewEnd)
}
}
// noncePattern finds the generated words in a string.
var noncePattern = regexp.MustCompile(`w[0-9]+`)
// noncesOf reduces text to the sequence of generated words in it.
//
// The comparison is on the nonces rather than on the tokens because the two
// questions are separable and only one of them is this test's. Whether a
// separator lands between two words is prosediff's Span.Space, which has a
// known defect — an equal run takes its flag from the old revision's token, so
// an insertion at the head of a block renders as "{+new+}old" with the words
// run together — and it is a defect in a package this test cannot reach.
// Whether a row's words are the words of the line it names is this test's whole
// point, and it survives that defect: two nonces merged into one string still
// yield both nonces, in order, while a row that reached for a neighbouring line
// yields the wrong ones. Nonces also let the check ignore the syntax a block
// legitimately drops — a heading's "#", a fence's backticks.
func noncesOf(s string) []string {
return noncePattern.FindAllString(s, -1)
}
// rowPlainText is the text a row puts on the page, without the [-…-] / {+…+}
// markers summarize adds for readability — those are the test's punctuation,
// and tokenizing them would compare the test against itself.
func rowPlainText(row diffRow) string {
var b strings.Builder
for i, s := range row.Spans {
if s.Space && i > 0 {
b.WriteByte(' ')
}
b.WriteString(s.Text)
}
return b.String()
}
// inOrderSubsequence reports whether every token of sub appears in super, in
// order. Subsequence rather than equality because a row legitimately drops
// syntax the line carries — a heading's "#", a list item's marker — and never
// legitimately adds a word.
func inOrderSubsequence(sub, super []string) bool {
i := 0
for _, s := range super {
if i < len(sub) && sub[i] == s {
i++
}
}
return i == len(sub)
}