Skip to content

Commit ddbfd2f

Browse files
author
merge-queue-bot
committed
Merge PR #693: test: add dedicated unit tests for export helpers and two small rename helpers (plan 2606240213)
2 parents 3d35b77 + aaf247f commit ddbfd2f

5 files changed

Lines changed: 335 additions & 15 deletions

File tree

PLAN.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,6 @@ footer: |
226226
| 2606231014 || sonnet | [Add dedicated unit tests for samefileanchor helper functions](plan/2606231014_arch-fix-samefileanchor-helper-tests.md) |
227227
| 2606240211 || sonnet | [Add dedicated unit tests for locate.go helpers](plan/2606240211_arch-fix-locate-helper-tests.md) |
228228
| 2606240212 || sonnet | [Add dedicated unit tests for lsp/rename.go helpers](plan/2606240212_arch-fix-lsp-rename-helper-tests.md) |
229-
| 2606240213 | 🔲 | sonnet | [Add dedicated unit tests for export.go helpers and two small rename helpers](plan/2606240213_arch-fix-export-helper-tests.md) |
229+
| 2606240213 | | sonnet | [Add dedicated unit tests for export.go helpers and two small rename helpers](plan/2606240213_arch-fix-export-helper-tests.md) |
230230
| 2606240214 || sonnet | [Remove duplicated helpers between lsp/rename.go and rename/rename.go](plan/2606240214_arch-fix-rename-dedup.md) |
231231
<?/catalog?>

internal/export/helpers_test.go

Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
package export
2+
3+
import (
4+
"testing"
5+
"testing/fstest"
6+
7+
"github.com/jeduden/mdsmith/internal/gitignore"
8+
"github.com/jeduden/mdsmith/internal/lint"
9+
"github.com/jeduden/mdsmith/internal/piparser"
10+
"github.com/jeduden/mdsmith/internal/rule"
11+
_ "github.com/jeduden/mdsmith/internal/rules/all"
12+
13+
"github.com/stretchr/testify/assert"
14+
"github.com/stretchr/testify/require"
15+
)
16+
17+
func TestSelectDirectives(t *testing.T) {
18+
assert.Nil(t, selectDirectives(nil))
19+
assert.Nil(t, selectDirectives([]rule.Rule{}))
20+
21+
got := selectDirectives(rule.All())
22+
require.NotEmpty(t, got)
23+
24+
for i := 1; i < len(got); i++ {
25+
assert.LessOrEqual(t, got[i-1].directive.Name(), got[i].directive.Name())
26+
}
27+
for _, d := range got {
28+
assert.NotNil(t, d.rule)
29+
assert.NotNil(t, d.directive)
30+
}
31+
}
32+
33+
func TestAllDirectiveNames(t *testing.T) {
34+
got := allDirectiveNames()
35+
require.NotEmpty(t, got)
36+
37+
for i := 1; i < len(got); i++ {
38+
assert.LessOrEqual(t, got[i-1].name, got[i].name)
39+
}
40+
for _, d := range got {
41+
assert.NotEmpty(t, d.name)
42+
assert.NotEmpty(t, d.ruleID)
43+
assert.NotEmpty(t, d.ruleName)
44+
}
45+
}
46+
47+
func TestRegenerate(t *testing.T) {
48+
src := "# Title\n\n<?toc?>\n\n- [Wrong](#wrong)\n\n<?/toc?>\n\n## Section\n\nbody\n"
49+
orig, err := lint.NewFile("doc.md", []byte(src))
50+
require.NoError(t, err)
51+
52+
result := regenerate(orig, selectDirectives(rule.All()))
53+
54+
require.NotNil(t, result)
55+
assert.NotEqual(t, src, string(result.Source))
56+
assert.Contains(t, string(result.Source), "[Section](#section)")
57+
}
58+
59+
func TestHydrate(t *testing.T) {
60+
orig, err := lint.NewFile("orig.md", []byte("# Hello\n"))
61+
require.NoError(t, err)
62+
orig.FS = fstest.MapFS{"a.md": &fstest.MapFile{Data: []byte("a")}}
63+
orig.RootDir = "/repo"
64+
orig.MaxInputBytes = int64(999)
65+
orig.RootFS = fstest.MapFS{"b.md": &fstest.MapFile{Data: []byte("b")}}
66+
orig.GitignoreFunc = func() *gitignore.Matcher { return nil }
67+
68+
parsed, err := lint.NewFile("parsed.md", []byte("# World\n"))
69+
require.NoError(t, err)
70+
71+
hydrate(parsed, orig)
72+
73+
assert.Equal(t, "/repo", parsed.RootDir)
74+
assert.Equal(t, int64(999), parsed.MaxInputBytes)
75+
assert.Equal(t, orig.FS, parsed.FS)
76+
assert.Equal(t, orig.RootFS, parsed.RootFS)
77+
assert.NotNil(t, parsed.GitignoreFunc)
78+
}
79+
80+
func TestCheckStaleness(t *testing.T) {
81+
directives := selectDirectives(rule.All())
82+
83+
t.Run("fresh body produces no diagnostics", func(t *testing.T) {
84+
src := "# Title\n\n<?toc?>\n\n- [Section](#section)\n\n<?/toc?>\n\n## Section\n\nbody\n"
85+
f, err := lint.NewFile("doc.md", []byte(src))
86+
require.NoError(t, err)
87+
assert.Empty(t, checkStaleness(f, directives))
88+
})
89+
90+
t.Run("stale body produces error diagnostics", func(t *testing.T) {
91+
src := "# Title\n\n<?toc?>\n\n- [Wrong](#wrong)\n\n<?/toc?>\n\n## Section\n\nbody\n"
92+
f, err := lint.NewFile("doc.md", []byte(src))
93+
require.NoError(t, err)
94+
diags := checkStaleness(f, directives)
95+
require.NotEmpty(t, diags)
96+
assert.Equal(t, lint.Error, diags[0].Severity)
97+
})
98+
}
99+
100+
func TestInGeneratedRange(t *testing.T) {
101+
ranges := []lint.LineRange{{From: 3, To: 7}}
102+
103+
assert.True(t, inGeneratedRange(3, ranges))
104+
assert.True(t, inGeneratedRange(5, ranges))
105+
assert.True(t, inGeneratedRange(7, ranges))
106+
assert.False(t, inGeneratedRange(2, ranges))
107+
assert.False(t, inGeneratedRange(8, ranges))
108+
assert.False(t, inGeneratedRange(5, nil))
109+
}
110+
111+
func TestStripDirectives(t *testing.T) {
112+
t.Run("directive-free file passes through verbatim", func(t *testing.T) {
113+
src := "# Title\n\nbody\n"
114+
f, err := lint.NewFile("doc.md", []byte(src))
115+
require.NoError(t, err)
116+
got := stripDirectives(f, allDirectiveNames())
117+
assert.Equal(t, src, string(got))
118+
})
119+
120+
t.Run("toc markers removed body kept", func(t *testing.T) {
121+
src := "# Title\n\n<?toc?>\n\n- [Section](#section)\n\n<?/toc?>\n\n## Section\n\nbody\n"
122+
f, err := lint.NewFile("doc.md", []byte(src))
123+
require.NoError(t, err)
124+
got := stripDirectives(f, allDirectiveNames())
125+
s := string(got)
126+
assert.NotContains(t, s, "<?toc")
127+
assert.Contains(t, s, "- [Section](#section)")
128+
})
129+
130+
t.Run("markerless PI removed", func(t *testing.T) {
131+
src := "# Title\n\n<?allow-empty-section?>\n\nbody\n"
132+
f, err := lint.NewFile("doc.md", []byte(src))
133+
require.NoError(t, err)
134+
got := stripDirectives(f, allDirectiveNames())
135+
assert.NotContains(t, string(got), "<?allow-empty-section?>")
136+
assert.Contains(t, string(got), "body")
137+
})
138+
}
139+
140+
func TestPiLineRange(t *testing.T) {
141+
t.Run("single-line PI returns same start and end", func(t *testing.T) {
142+
src := "<?allow-empty-section?>\n"
143+
f, err := lint.NewFile("doc.md", []byte(src))
144+
require.NoError(t, err)
145+
146+
pi := helperFirstPI(t, f)
147+
start, end := piLineRange(pi, f)
148+
assert.Equal(t, 1, start)
149+
assert.Equal(t, 1, end)
150+
})
151+
152+
t.Run("multi-line PI closure on later line", func(t *testing.T) {
153+
src := "<?require\nfilename: \"*.md\"\n?>\n"
154+
f, err := lint.NewFile("doc.md", []byte(src))
155+
require.NoError(t, err)
156+
157+
pi := helperFirstPI(t, f)
158+
start, end := piLineRange(pi, f)
159+
assert.Equal(t, 1, start)
160+
assert.Equal(t, 3, end)
161+
})
162+
}
163+
164+
// helperFirstPI walks f.AST and returns the first ProcessingInstruction node.
165+
func helperFirstPI(t *testing.T, f *lint.File) *piparser.ProcessingInstruction {
166+
t.Helper()
167+
for n := f.AST.FirstChild(); n != nil; n = n.NextSibling() {
168+
if pi, ok := n.(*piparser.ProcessingInstruction); ok {
169+
return pi
170+
}
171+
}
172+
t.Fatal("no ProcessingInstruction node found in AST")
173+
return nil
174+
}
175+
176+
func TestOverlapsAny(t *testing.T) {
177+
set := map[int]struct{}{2: {}, 5: {}}
178+
179+
assert.True(t, overlapsAny(1, 3, set))
180+
assert.True(t, overlapsAny(5, 5, set))
181+
assert.False(t, overlapsAny(3, 4, set))
182+
assert.False(t, overlapsAny(6, 8, set))
183+
// from > to: loop never executes
184+
assert.False(t, overlapsAny(3, 1, set))
185+
}
186+
187+
func TestEmitLines(t *testing.T) {
188+
lines := [][]byte{
189+
[]byte("line one"),
190+
[]byte("line two"),
191+
[]byte("line three"),
192+
}
193+
194+
t.Run("empty strip set emits all lines", func(t *testing.T) {
195+
got := emitLines(lines, map[int]struct{}{})
196+
assert.Equal(t, "line one\nline two\nline three", string(got))
197+
})
198+
199+
t.Run("strip middle line", func(t *testing.T) {
200+
got := emitLines(lines, map[int]struct{}{2: {}})
201+
assert.Equal(t, "line one\nline three", string(got))
202+
})
203+
204+
t.Run("strip first line", func(t *testing.T) {
205+
got := emitLines(lines, map[int]struct{}{1: {}})
206+
assert.Equal(t, "line two\nline three", string(got))
207+
})
208+
209+
t.Run("strip all lines produces empty", func(t *testing.T) {
210+
got := emitLines(lines, map[int]struct{}{1: {}, 2: {}, 3: {}})
211+
assert.Empty(t, string(got))
212+
})
213+
}
214+
215+
func TestNormalizeBlankLines(t *testing.T) {
216+
noCode := map[int]struct{}{}
217+
218+
t.Run("nil input returns nil", func(t *testing.T) {
219+
assert.Nil(t, normalizeBlankLines(nil, noCode))
220+
})
221+
222+
t.Run("empty input returns empty", func(t *testing.T) {
223+
assert.Empty(t, normalizeBlankLines([]byte{}, noCode))
224+
})
225+
226+
t.Run("leading and trailing blanks trimmed", func(t *testing.T) {
227+
got := normalizeBlankLines([]byte("\n\nparagraph\n\n"), noCode)
228+
assert.Equal(t, "paragraph\n", string(got))
229+
})
230+
231+
t.Run("multiple consecutive blanks collapsed to one", func(t *testing.T) {
232+
got := normalizeBlankLines([]byte("a\n\n\n\nb\n"), noCode)
233+
assert.Equal(t, "a\n\nb\n", string(got))
234+
})
235+
236+
t.Run("result ends with exactly one newline", func(t *testing.T) {
237+
got := normalizeBlankLines([]byte("a\nb\n"), noCode)
238+
assert.Equal(t, "a\nb\n", string(got))
239+
})
240+
241+
t.Run("blank lines inside code block preserved", func(t *testing.T) {
242+
// Lines 2 and 3 are blank lines inside a code block; normaliseBlankLines
243+
// treats inCode blank lines as non-blank so they survive collapse.
244+
codeLines := map[int]struct{}{2: {}, 3: {}}
245+
src := []byte("text\n\n\ncode blank\ntext2\n")
246+
got := normalizeBlankLines(src, codeLines)
247+
assert.Equal(t, "text\n\n\ncode blank\ntext2\n", string(got))
248+
})
249+
250+
t.Run("all blank content normalises to nil", func(t *testing.T) {
251+
got := normalizeBlankLines([]byte("\n\n\n"), noCode)
252+
assert.Nil(t, got)
253+
})
254+
}

internal/rename/helpers_test.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@ package rename
33
import (
44
"testing"
55

6+
"github.com/jeduden/mdsmith/internal/lint"
67
"github.com/jeduden/mdsmith/pkg/goldmark/ast"
8+
"github.com/jeduden/mdsmith/pkg/goldmark/parser"
9+
"github.com/jeduden/mdsmith/pkg/goldmark/text"
710
"github.com/stretchr/testify/assert"
811
)
912

@@ -129,6 +132,43 @@ func TestRefDefEditsInBody_DefLinePastLineTable(t *testing.T) {
129132
assert.Empty(t, edits)
130133
}
131134

135+
func TestContentBlockLines(t *testing.T) {
136+
// A paragraph's lines are consumed into the AST block; a ref def's are not.
137+
// contentBlockLines skips LinkReferenceDefinition nodes, so the ref def
138+
// line does not appear in the result.
139+
body := []byte("paragraph text\n\n[label]: https://example.com\n")
140+
root := lint.NewParser().Parse(text.NewReader(body), parser.WithContext(parser.NewContext()))
141+
got := contentBlockLines(root, body)
142+
143+
_, hasPara := got[1]
144+
assert.True(t, hasPara, "paragraph line should appear in content block lines")
145+
146+
_, hasDef := got[3]
147+
assert.False(t, hasDef, "ref def line must not appear (LinkReferenceDefinition skipped)")
148+
}
149+
150+
func TestContentBlockLines_EmptyBody(t *testing.T) {
151+
body := []byte{}
152+
root := lint.NewParser().Parse(text.NewReader(body), parser.WithContext(parser.NewContext()))
153+
got := contentBlockLines(root, body)
154+
assert.NotNil(t, got)
155+
assert.Empty(t, got)
156+
}
157+
158+
func TestContentBlockLines_CodeBlockLinesConsumed(t *testing.T) {
159+
// Lines inside a fenced code block are block-level AST nodes;
160+
// their lines appear in the result.
161+
body := []byte("```\ncode line\n```\n\n[ref]: u\n")
162+
root := lint.NewParser().Parse(text.NewReader(body), parser.WithContext(parser.NewContext()))
163+
got := contentBlockLines(root, body)
164+
165+
_, hasCode := got[2]
166+
assert.True(t, hasCode, "code block content line should appear in content block lines")
167+
168+
_, hasDef := got[5]
169+
assert.False(t, hasDef, "ref def line must not appear")
170+
}
171+
132172
func TestLinkRef_EmptyTextReferenceUseSkipped(t *testing.T) {
133173
// `[][spec]` is a full reference with empty display text:
134174
// linkTextBounds can't anchor it, so refUseEdit skips that use

internal/rules/concisenessscoring/rule_test.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,31 @@ func TestCheck_MessageNoConcatenationWhenExamplesPresent(t *testing.T) {
321321
assert.Contains(t, msg, "e.g.,", "message with cues must include formatted examples")
322322
}
323323

324+
func TestCountClassifierTokens(t *testing.T) {
325+
cases := []struct {
326+
name string
327+
input string
328+
want int
329+
}{
330+
{"empty string", "", 0},
331+
{"single word", "hello", 1},
332+
{"two words", "hello world", 2},
333+
{"apostrophe keeps token together", "can't", 1},
334+
{"hyphen splits tokens", "hello-world", 2},
335+
{"digit sequence counts as token", "abc 123", 2},
336+
{"punctuation separates tokens", "hello, world!", 2},
337+
{"leading and trailing spaces", " hello ", 1},
338+
{"uppercase letters included", "Hello World", 2},
339+
{"only punctuation", "!@#$", 0},
340+
{"mixed alphanumeric token", "abc123", 1},
341+
}
342+
for _, tc := range cases {
343+
t.Run(tc.name, func(t *testing.T) {
344+
assert.Equal(t, tc.want, countClassifierTokens(tc.input))
345+
})
346+
}
347+
}
348+
324349
func TestCheck_NoCuesMessage(t *testing.T) {
325350
// Exercises the if examples == "" branch: a paragraph that scores as
326351
// verbose but produces no cue phrases yields the base message only.

plan/2606240213_arch-fix-export-helper-tests.md

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ id: 2606240213
33
title: >-
44
Add dedicated unit tests for export.go helpers
55
and two small rename helpers
6-
status: "🔲"
6+
status: ""
77
model: sonnet
88
summary: >-
99
internal/export/export.go has 11 unexported
@@ -66,16 +66,17 @@ Functions without a dedicated test:
6666

6767
## Acceptance Criteria
6868

69-
- [ ] `export_test.go` contains
70-
`TestselectDirectives`, `TestallDirectiveNames`,
71-
`Testregenerate`, `Testhydrate`,
72-
`TestcheckStaleness`, `TestinGeneratedRange`,
73-
`TeststripDirectives`, `TestpiLineRange`,
74-
`TestoverlapsAny`, `TestemitLines`,
75-
`TestnormalizeBlankLines`.
76-
- [ ] `rule_test.go` contains
77-
`TestcountClassifierTokens`.
78-
- [ ] `rename_test.go` contains
79-
`TestcontentBlockLines`.
80-
- [ ] All three `go test` commands are green.
81-
- [ ] `mdsmith check .` is green.
69+
- [x] `helpers_test.go` (package export) contains
70+
`TestSelectDirectives`, `TestAllDirectiveNames`,
71+
`TestRegenerate`, `TestHydrate`,
72+
`TestCheckStaleness`, `TestInGeneratedRange`,
73+
`TestStripDirectives`, `TestPiLineRange`,
74+
`TestOverlapsAny`, `TestEmitLines`,
75+
`TestNormalizeBlankLines`. (Go requires
76+
uppercase after `Test`; plan names adjusted.)
77+
- [x] `rule_test.go` contains
78+
`TestCountClassifierTokens`.
79+
- [x] `helpers_test.go` (package rename) contains
80+
`TestContentBlockLines`.
81+
- [x] All three `go test` commands are green.
82+
- [x] `mdsmith check .` is green.

0 commit comments

Comments
 (0)