package core
import (
"errors"
"testing"
)
// blocks builds a revision out of "heading/path|hash" shorthand, numbering each
// block within its heading path exactly as AnchorBlocks does.
func blocks(specs ...string) []AnchorBlock {
hashes := make([]string, len(specs))
paths := make([][]string, len(specs))
for i, s := range specs {
var path []string
hash := s
for j := len(s) - 1; j >= 0; j-- {
if s[j] == '|' {
hash = s[j+1:]
if head := s[:j]; head != "" {
path = split(head, '/')
}
break
}
}
hashes[i], paths[i] = hash, path
}
return AnchorBlocks(hashes, paths)
}
func split(s string, sep byte) []string {
var out []string
start := 0
for i := range len(s) {
if s[i] == sep {
out = append(out, s[start:i])
start = i + 1
}
}
return append(out, s[start:])
}
func anchor(headings []string, index int, hash string) CommentAnchor {
return CommentAnchor{DocID: "SPEC-0007", HeadingPath: headings, Index: index, BlockHash: hash, Side: SideNew}
}
// Content is the strongest evidence an anchor has. A paragraph that reflowed,
// or that a dozen insertions above pushed down the document, is still the same
// paragraph, and the comment on it must not be disturbed.
func TestAnchorFollowsContentWhereverItMoves(t *testing.T) {
rev := blocks("Storage|aaa", "Storage|bbb", "Storage|ccc")
a := anchor([]string{"Storage"}, 2, "ccc")
// Two blocks inserted above: the commented block is now at index 4.
moved := blocks("Storage|new1", "Storage|new2", "Storage|aaa", "Storage|bbb", "Storage|ccc")
for name, tc := range map[string]struct {
rev []AnchorBlock
want int
}{
"unchanged": {rev, 2},
"shifted by two": {moved, 4},
} {
got, state := ResolveAnchor(a, tc.rev)
if got != tc.want || state != AnchorExact {
t.Errorf("%s: ResolveAnchor = (%d, %s), want (%d, %s)", name, got, state, tc.want, AnchorExact)
}
}
}
// Renaming a section must not orphan the comments inside it. This is why
// HeadingPath is kept out of the block hash: the content is untouched, so the
// hash still matches and the comment survives a rename it had nothing to do
// with.
func TestSectionRenameDoesNotOrphanItsComments(t *testing.T) {
a := anchor([]string{"Storage"}, 1, "bbb")
renamed := blocks("Storage model|aaa", "Storage model|bbb")
got, state := ResolveAnchor(a, renamed)
if got != 1 || state != AnchorExact {
t.Errorf("ResolveAnchor = (%d, %s), want (1, %s)", got, state, AnchorExact)
}
}
// The fallback: the text was edited, so no hash matches, but a block still sits
// at that spot under those headings. The comment is shown against it and marked
// edited, because the critique may no longer fit the words.
func TestEditedBlockKeepsTheCommentAndSaysSo(t *testing.T) {
a := anchor([]string{"Storage"}, 1, "bbb")
edited := blocks("Storage|aaa", "Storage|bbb-rewritten", "Storage|ccc")
got, state := ResolveAnchor(a, edited)
if got != 1 || state != AnchorEdited {
t.Errorf("ResolveAnchor = (%d, %s), want (1, %s)", got, state, AnchorEdited)
}
}
// The index is within the heading path, not the document. An edit in an earlier
// section changes every document-global position below it, and the fallback
// exists precisely for blocks whose content changed — so a global index would
// fail exactly when it is needed.
func TestIndexIsRelativeToTheSectionNotTheDocument(t *testing.T) {
// The comment is on the second block of "Storage": document index 3.
a := anchor([]string{"Storage"}, 1, "bbb")
// A block is added to the earlier section and the commented block is
// rewritten, so only the positional fallback can fire.
rev := blocks("Intro|i1", "Intro|i2", "Intro|i3", "Storage|aaa", "Storage|bbb-rewritten")
got, state := ResolveAnchor(a, rev)
if state != AnchorEdited {
t.Fatalf("state = %s, want %s (a section-relative index survives an insertion above)", state, AnchorEdited)
}
if got != 4 {
t.Errorf("ResolveAnchor = %d, want 4 (the second Storage block, now at document index 4)", got)
}
}
// Nothing matched. The comment is kept and reported as outdated rather than
// relocated to a best guess: a comment on the wrong paragraph is worse than one
// that admits it lost its place, because the reader cannot tell it is wrong.
func TestNothingMatchedIsOutdatedNotRelocated(t *testing.T) {
a := anchor([]string{"Storage"}, 4, "bbb")
gone := blocks("Intro|i1", "Rationale|r1")
got, state := ResolveAnchor(a, gone)
if got != -1 || state != AnchorOutdated {
t.Errorf("ResolveAnchor = (%d, %s), want (-1, %s)", got, state, AnchorOutdated)
}
}
// A document repeats itself — "TBD" appears verbatim under half the headings —
// so a hash match alone does not identify a block. The comment's own section
// wins outright, however far the block moved inside it; otherwise which
// duplicate a comment landed on would be decided by document order.
func TestDuplicateContentIsDisambiguatedByItsSection(t *testing.T) {
rev := blocks("Intro|TBD", "Rationale|TBD", "Storage|s1", "Storage|s2", "Storage|s3", "Storage|TBD")
got, state := ResolveAnchor(anchor([]string{"Storage"}, 3, "TBD"), rev)
if got != 5 || state != AnchorExact {
t.Errorf("ResolveAnchor = (%d, %s), want (5, %s) — Storage's own TBD", got, state, AnchorExact)
}
// With no section of its own to prefer, the nearest index decides, and it
// does so deterministically rather than by walk order.
got, _ = ResolveAnchor(anchor([]string{"Gone"}, 0, "TBD"), rev)
if got != 0 {
t.Errorf("ResolveAnchor = %d, want 0 (nearest index when no section matches)", got)
}
}
// A comment on a block with no enclosing heading — the document preamble — is
// an ordinary case, not a missing value.
func TestPreambleBlocksAnchorLikeAnyOther(t *testing.T) {
rev := blocks("|p1", "|p2", "Storage|s1")
got, state := ResolveAnchor(anchor(nil, 1, "p2"), rev)
if got != 1 || state != AnchorExact {
t.Errorf("ResolveAnchor = (%d, %s), want (1, %s)", got, state, AnchorExact)
}
}
// Heading paths are compared whole. Two sections whose names concatenate to the
// same string are different sections, and a comment must not cross between them.
func TestHeadingPathsDoNotCollideByConcatenation(t *testing.T) {
rev := blocks("A/BC|x", "AB/C|y")
got, _ := ResolveAnchor(anchor([]string{"AB", "C"}, 0, "y"), rev)
if got != 1 {
t.Errorf("ResolveAnchor = %d, want 1; [A BC] and [AB C] are different sections", got)
}
}
func TestParseCommentSide(t *testing.T) {
for _, s := range []string{"new", "old"} {
if _, err := ParseCommentSide(s); err != nil {
t.Errorf("ParseCommentSide(%q): %v", s, err)
}
}
if _, err := ParseCommentSide("both"); !errors.Is(err, ErrInvalidCommentSide) {
t.Errorf("ParseCommentSide(both) error = %v, want ErrInvalidCommentSide", err)
}
}