Skip to content

Commit 10d2692

Browse files
author
merge-queue-bot
committed
Merge PR #687: test(samefileanchor): add dedicated unit tests for 12 unexported helpers
2 parents dabbdc0 + 3754744 commit 10d2692

3 files changed

Lines changed: 353 additions & 0 deletions

File tree

PLAN.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,4 +222,5 @@ footer: |
222222
| 2606211908 || | [arch-fix: split internal/lint/layer0.go](plan/2606211908_arch-fix-layer0-split.md) |
223223
| 2606211909 || | [arch-fix: split internal/lsp/server.go](plan/2606211909_arch-fix-lsp-server-split.md) |
224224
| 2606211910 || | [arch-fix: add trivial-accessor exemption comments in workspace.go](plan/2606211910_arch-fix-workspace-exemptions.md) |
225+
| 2606231014 || sonnet | [Add dedicated unit tests for samefileanchor helper functions](plan/2606231014_arch-fix-samefileanchor-helper-tests.md) |
225226
<?/catalog?>
Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
1+
package samefileanchor
2+
3+
import (
4+
"testing"
5+
6+
"github.com/jeduden/mdsmith/internal/lint"
7+
"github.com/jeduden/mdsmith/pkg/goldmark/ast"
8+
goldmarktext "github.com/jeduden/mdsmith/pkg/goldmark/text"
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
func TestSameFileFragment(t *testing.T) {
14+
assert.Nil(t, sameFileFragment(nil), "nil input returns nil")
15+
assert.Nil(t, sameFileFragment([]byte{}), "empty input returns nil")
16+
assert.Nil(t, sameFileFragment([]byte("other.md#section")), "path+fragment returns nil")
17+
assert.Nil(t, sameFileFragment([]byte("https://example.com#foo")), "absolute URL returns nil")
18+
assert.Equal(t, []byte(""), sameFileFragment([]byte("#")), "bare # returns empty fragment")
19+
assert.Equal(t, []byte("section"), sameFileFragment([]byte("#section")), "same-file fragment")
20+
assert.Equal(t, []byte("my-heading"), sameFileFragment([]byte("#my-heading")), "hyphenated fragment")
21+
}
22+
23+
func TestAppendHeadingTextRaw(t *testing.T) {
24+
cases := []struct {
25+
name string
26+
input string
27+
want string
28+
}{
29+
{"plain text", "Hello World", "Hello World"},
30+
{"link text only", "[click here](url)", "click here"},
31+
{"image alt text stripped", "![logo](logo.png)", "logo"},
32+
{"image prefix before text", "![alt](src) after", "alt after"},
33+
// appendHeadingTextRaw preserves inner bracket chars (depth-tracked) in output.
34+
{"nested brackets", "[outer [inner] rest](url)", "outer [inner] rest"},
35+
{"paren URL with nested parens", "[text](url(inner))", "text"},
36+
{"ref link label discarded", "[text][ref]", "text"},
37+
{"no markup passthrough", "no links", "no links"},
38+
}
39+
for _, tc := range cases {
40+
t.Run(tc.name, func(t *testing.T) {
41+
got := appendHeadingTextRaw(nil, []byte(tc.input))
42+
assert.Equal(t, tc.want, string(got))
43+
})
44+
}
45+
}
46+
47+
func TestSkipLinkDest(t *testing.T) {
48+
// Normal inline link destination.
49+
text := []byte("(url) trailing")
50+
assert.Equal(t, 5, skipLinkDest(text, 0), "advances past (url)")
51+
52+
// Not a '(' at position — stays put.
53+
assert.Equal(t, 5, skipLinkDest(text, 5), "non-( position unchanged")
54+
55+
// Nested parentheses in URL (Wikipedia-style).
56+
nested := []byte("(outer(inner)) rest")
57+
assert.Equal(t, 14, skipLinkDest(nested, 0), "nested parens consumed")
58+
59+
// Out-of-bounds i — returns i.
60+
assert.Equal(t, 100, skipLinkDest([]byte("abc"), 100), "out-of-bounds returns i")
61+
62+
// Empty content between parens.
63+
assert.Equal(t, 2, skipLinkDest([]byte("()"), 0), "empty dest")
64+
}
65+
66+
func TestSkipRefLabel(t *testing.T) {
67+
text := []byte("[ref] trailing")
68+
assert.Equal(t, 5, skipRefLabel(text, 0), "advances past [ref]")
69+
70+
// Not a '[' at position — stays put.
71+
assert.Equal(t, 5, skipRefLabel(text, 5), "non-[ position unchanged")
72+
73+
// Unterminated label — advances to end.
74+
unterminated := []byte("[noclose")
75+
assert.Equal(t, len(unterminated), skipRefLabel(unterminated, 0), "unterminated label reaches end")
76+
77+
// Out-of-bounds i — returns i.
78+
assert.Equal(t, 100, skipRefLabel([]byte("abc"), 100), "out-of-bounds returns i")
79+
80+
// Empty label.
81+
assert.Equal(t, 2, skipRefLabel([]byte("[]"), 0), "empty label")
82+
}
83+
84+
func TestInsertDisambiguated(t *testing.T) {
85+
slugs := make(map[string]struct{})
86+
counts := make(map[string]int)
87+
88+
insertDisambiguated(slugs, counts, "intro")
89+
assert.Contains(t, slugs, "intro", "first insertion uses bare slug")
90+
91+
insertDisambiguated(slugs, counts, "intro")
92+
assert.Contains(t, slugs, "intro-1", "second insertion gets -1 suffix")
93+
94+
insertDisambiguated(slugs, counts, "intro")
95+
assert.Contains(t, slugs, "intro-2", "third insertion gets -2 suffix")
96+
97+
// A different slug does not inherit the intro counter.
98+
insertDisambiguated(slugs, counts, "other")
99+
assert.Contains(t, slugs, "other", "different slug inserted without suffix")
100+
assert.NotContains(t, slugs, "other-1", "different slug not disambiguated")
101+
102+
// Pre-seeded collision: "fix-1" already exists before the second "fix"
103+
// arrives. The inner loop must skip "fix-1" and land on "fix-2".
104+
slugs2 := map[string]struct{}{"fix": {}, "fix-1": {}}
105+
counts2 := map[string]int{}
106+
insertDisambiguated(slugs2, counts2, "fix")
107+
require.Contains(t, slugs2, "fix-2", "inner loop skips pre-existing fix-1")
108+
require.NotContains(t, slugs2, "fix-3", "stops at first free slot")
109+
// A follow-up insertion verifies that counts2 was written back correctly
110+
// (counts2["fix"]==2 means the next probe starts at 3, not re-scanning from 1).
111+
insertDisambiguated(slugs2, counts2, "fix")
112+
assert.Contains(t, slugs2, "fix-3", "fourth fix uses counts write-back to start at 3")
113+
}
114+
115+
func TestAtxHeadingText(t *testing.T) {
116+
cases := []struct {
117+
name string
118+
input string
119+
want string
120+
}{
121+
{"basic h1", "# Hello\n", "Hello"},
122+
{"h2", "## World\n", "World"},
123+
{"closing markers stripped", "## Section ##\n", "Section"},
124+
{"leading spaces (3)", " # Indented\n", "Indented"},
125+
{"tab after marker", "#\tTabbed\n", "Tabbed"},
126+
{"CRLF ending", "# Heading\r\n", "Heading"},
127+
{"empty body", "#\n", ""},
128+
// NOTE: CommonMark §4.2 allows trailing spaces after closing ##; the
129+
// implementation strips only \r\n before detecting closing hashes, so
130+
// spaces that precede ## prevent detection and leak into the text.
131+
// This is a known spec deviation: "## Title ## \n" slugifies to
132+
// "title-" on mdsmith but "title" on GitHub. Tracked as a known gap.
133+
{"closing hashes with trailing space", "## Title ## \n", "Title ##"},
134+
{"no space after marker", "#NoSpace\n", "NoSpace"},
135+
}
136+
for _, tc := range cases {
137+
t.Run(tc.name, func(t *testing.T) {
138+
got := atxHeadingText([]byte(tc.input))
139+
assert.Equal(t, tc.want, string(got))
140+
})
141+
}
142+
}
143+
144+
func TestAppendSlug(t *testing.T) {
145+
cases := []struct {
146+
name string
147+
input string
148+
want string
149+
}{
150+
{"lowercase passthrough", "hello", "hello"},
151+
{"uppercase lowercased", "HELLO", "hello"},
152+
{"space to hyphen", "hello world", "hello-world"},
153+
{"tab to hyphen", "hello\tworld", "hello-world"},
154+
{"punctuation dropped", "hello, world!", "hello-world"},
155+
{"digits kept", "test123", "test123"},
156+
{"hyphen kept", "already-hyphenated", "already-hyphenated"},
157+
{"empty input", "", ""},
158+
{"unicode letter kept", "café", "café"},
159+
{"unicode non-letter dropped", "test→value", "testvalue"},
160+
{"unicode uppercase lowercased", "ÜBER", "über"},
161+
}
162+
for _, tc := range cases {
163+
t.Run(tc.name, func(t *testing.T) {
164+
got := appendSlug(nil, []byte(tc.input))
165+
assert.Equal(t, tc.want, string(got))
166+
})
167+
}
168+
}
169+
170+
func TestAppendHeadingText(t *testing.T) {
171+
// "# Hello World\n"
172+
// 0123456789012 3
173+
src := []byte("# Hello World\n")
174+
h := ast.NewHeading(1)
175+
txt := ast.NewTextSegment(goldmarktext.NewSegment(2, 13)) // "Hello World"
176+
h.AppendChild(h, txt)
177+
178+
got := appendHeadingText(h, src, nil)
179+
assert.Equal(t, "Hello World", string(got))
180+
181+
// Nested markup: strong node wrapping a text node.
182+
// "# **Bold** Section\n"
183+
// 0123456789012345678
184+
src2 := []byte("# **Bold** Section\n")
185+
h2 := ast.NewHeading(1)
186+
em := ast.NewEmphasis(2)
187+
boldTxt := ast.NewTextSegment(goldmarktext.NewSegment(4, 8)) // src2[4:8] = "Bold"
188+
em.AppendChild(em, boldTxt)
189+
restTxt := ast.NewTextSegment(goldmarktext.NewSegment(10, 18)) // " Section"
190+
h2.AppendChild(h2, em)
191+
h2.AppendChild(h2, restTxt)
192+
193+
got2 := appendHeadingText(h2, src2, nil)
194+
assert.Equal(t, "Bold Section", string(got2))
195+
196+
// No children — empty result.
197+
empty := ast.NewHeading(1)
198+
got3 := appendHeadingText(empty, src, nil)
199+
assert.Empty(t, got3)
200+
}
201+
202+
func TestCollectSlugsNode(t *testing.T) {
203+
// "# First\n## Second\n"
204+
// 01234567890123456789
205+
src := []byte("# First\n## Second\n")
206+
slugs := make(map[string]struct{})
207+
counts := make(map[string]int)
208+
209+
h1 := ast.NewHeading(1)
210+
t1 := ast.NewTextSegment(goldmarktext.NewSegment(2, 7)) // "First"
211+
h1.AppendChild(h1, t1)
212+
213+
h2 := ast.NewHeading(2)
214+
t2 := ast.NewTextSegment(goldmarktext.NewSegment(11, 17)) // "Second"
215+
h2.AppendChild(h2, t2)
216+
217+
doc := ast.NewDocument()
218+
doc.AppendChild(doc, h1)
219+
doc.AppendChild(doc, h2)
220+
221+
// Use stack-allocated backing arrays to match the production calling
222+
// pattern in collectSlugsAST (nil works too, but this is more faithful).
223+
var textBuf [256]byte
224+
var slugBuf [512]byte
225+
collectSlugsNode(doc, src, slugs, counts, textBuf[:0], slugBuf[:0])
226+
assert.Contains(t, slugs, "first")
227+
assert.Contains(t, slugs, "second")
228+
assert.Len(t, slugs, 2)
229+
}
230+
231+
func TestCollectSlugsAST(t *testing.T) {
232+
f, err := lint.NewFile("test.md", []byte("# Alpha\n## Beta\n"))
233+
require.NoError(t, err)
234+
require.NotNil(t, f.AST, "test requires a parsed AST")
235+
236+
slugs := collectSlugsAST(f)
237+
assert.Contains(t, slugs, "alpha")
238+
assert.Contains(t, slugs, "beta")
239+
assert.Len(t, slugs, 2)
240+
241+
// No headings — collectSlugsAST must return nil (not an empty map) so
242+
// callers that nil-check the result (e.g. checkAST) work correctly.
243+
fEmpty, err := lint.NewFile("test.md", []byte("[link](#x).\n"))
244+
require.NoError(t, err)
245+
assert.Nil(t, collectSlugsAST(fEmpty), "no headings returns nil")
246+
}
247+
248+
func TestCollectSlugsLayer0(t *testing.T) {
249+
f := lint.NewFileLines("test.md", []byte("# Gamma\n## Delta\n"))
250+
slugs := collectSlugsLayer0(f)
251+
assert.Contains(t, slugs, "gamma")
252+
assert.Contains(t, slugs, "delta")
253+
assert.Len(t, slugs, 2)
254+
255+
// Setext heading — the BlockSetextHeading branch takes the first line of
256+
// the span as text and the second as the underline.
257+
fSetext := lint.NewFileLines("test.md", []byte("Setext Title\n============\n"))
258+
slugsSetext := collectSlugsLayer0(fSetext)
259+
assert.Contains(t, slugsSetext, "setext-title", "setext heading slug")
260+
261+
// Setext heading with a link in the text — exercises appendHeadingTextRaw
262+
// on the setext branch (link destination must be stripped, only alt text kept).
263+
fSetextLink := lint.NewFileLines("test.md", []byte("[Click Here](url)\n==================\n"))
264+
slugsSetextLink := collectSlugsLayer0(fSetextLink)
265+
assert.Contains(t, slugsSetextLink, "click-here", "setext heading with link strips dest")
266+
267+
// No headings — must return nil.
268+
fNone := lint.NewFileLines("test.md", []byte("plain paragraph\n"))
269+
assert.Nil(t, collectSlugsLayer0(fNone), "no headings returns nil")
270+
}
271+
272+
func TestCollectSlugs(t *testing.T) {
273+
src := []byte("# One\n## Two\n")
274+
275+
// AST path (lint.NewFile parses a goldmark AST).
276+
fAST, err := lint.NewFile("test.md", src)
277+
require.NoError(t, err)
278+
require.NotNil(t, fAST.AST, "test requires a parsed AST")
279+
slugsAST := collectSlugs(fAST)
280+
assert.Contains(t, slugsAST, "one")
281+
assert.Contains(t, slugsAST, "two")
282+
283+
// nil-AST (Layer0) path.
284+
fLines := lint.NewFileLines("test.md", src)
285+
require.Nil(t, fLines.AST, "test requires nil AST")
286+
slugsL0 := collectSlugs(fLines)
287+
assert.Contains(t, slugsL0, "one")
288+
assert.Contains(t, slugsL0, "two")
289+
assert.Equal(t, slugsAST, slugsL0, "AST and Layer0 paths must produce identical slug sets")
290+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
---
2+
id: 2606231014
3+
title: Add dedicated unit tests for samefileanchor helper functions
4+
status: ""
5+
model: sonnet
6+
summary: >-
7+
internal/rules/samefileanchor/rule.go has 12
8+
unexported helper functions covered only through
9+
the higher-level TestRule_* suite. Add a named
10+
unit test for each so the audit policy is met.
11+
---
12+
# Add dedicated unit tests for samefileanchor helpers
13+
14+
## Goal
15+
16+
`internal/rules/samefileanchor/rule.go` (MDS070,
17+
plan 2606210840 / PR #675) has 12 unexported
18+
helpers. They are covered only through the 37-case
19+
`TestRule_*` suite. The audit policy requires a
20+
named test for each function.
21+
22+
## Background
23+
24+
Functions in `internal/rules/samefileanchor/
25+
rule.go` with no dedicated test as of commit
26+
1599c9f:
27+
28+
- `sameFileFragment`
29+
- `collectSlugs`
30+
- `collectSlugsAST`
31+
- `collectSlugsNode`
32+
- `appendHeadingText`
33+
- `appendHeadingTextRaw`
34+
- `skipLinkDest`
35+
- `skipRefLabel`
36+
- `insertDisambiguated`
37+
- `collectSlugsLayer0`
38+
- `atxHeadingText`
39+
- `appendSlug`
40+
41+
## Tasks
42+
43+
1. For each function above, add at least one
44+
test to `internal/rules/samefileanchor/
45+
helpers_test.go` (package `samefileanchor`,
46+
not the external `_test` package) named
47+
`TestFunctionName` (or `TestFunctionName_Variant`).
48+
2. Drive each helper directly — do not build a
49+
full `lint.File` unless the helper signature
50+
requires it.
51+
3. Run `go test ./internal/rules/samefileanchor/...`
52+
to confirm all pass.
53+
4. Run `go vet ./internal/rules/samefileanchor/...`
54+
and golangci-lint — both must pass.
55+
56+
## Acceptance Criteria
57+
58+
- [x] `helpers_test.go` (package `samefileanchor`)
59+
contains named tests for all 12 helpers listed above.
60+
- [x] `go test ./internal/rules/samefileanchor/...`
61+
is green.
62+
- [x] No new golangci-lint warnings.

0 commit comments

Comments
 (0)