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 " / ", 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 fence whose language changed and whose body did not used to render as a // screen of context rows: prosediff hashes Info, so the two fences pair as a // modification, but Block.Lines excludes the delimiter the language is written // on, so the line script is entirely equal. The page said the document changed // and then showed nothing that had. The marker states it instead — the // delimiter is not a row of this table, and giving it a number would be a guess. func TestCodeFenceLanguageChangeIsStated(t *testing.T) { const old = "# H\n\n```go\na := 1\n```\n" const nw = "# H\n\n```python\na := 1\n```\n" checkRows(t, modifyRowsOf(t, old, nw), []string{ "move ·/· code block: go → python", "eq 4/4 a := 1", }) } // A fence that loses its language keeps the marker readable: an empty Info is a // bare ``` fence, which is a state and not a missing value. func TestCodeFenceLosingItsLanguageSaysSo(t *testing.T) { const old = "# H\n\n```go\na := 1\n```\n" const nw = "# H\n\n```\na := 1\n```\n" checkRows(t, modifyRowsOf(t, old, nw), []string{ "move ·/· code block: go → no language", "eq 4/4 a := 1", }) } // A body edit inside a fence whose language did not change gets no marker: the // line rows already say everything that happened. func TestUnchangedFenceLanguageAddsNoMarker(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" checkRows(t, modifyRowsOf(t, old, nw), []string{ "eq 4/4 a := 1", "del 5/· b := 2", "ins ·/5 b := 20", "eq 6/6 c := 3", }) } // 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 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) }