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