Skip to content

Commit 54aa006

Browse files
committed
schema: add coverage for plan 156 helpers
Codecov flagged the patch coverage on PR #295. Add focused tests for the new helpers introduced by plan 156: - claimsLaterLiteral: cover all four branches (slot, preamble, optional, already-claimed) plus the no-match case. - displayHeading: cover the three label-source paths (bare Heading, Matcher fallback, preamble empty). - claimRun (wrong-level): exercise the level-mismatch path via a nested schema whose inner heading appears at the outer level. - sequentialDiagMessage: cover the non-integer guard and the happy path. - cachedMatcher / compileMatcher: nil and invalid-pattern early-error branches. - matchHeading: nil-matcher branch and `digits` capture path. - fmvarLookup: missing-field, nil-fm, empty-name returns. - resolvePattern: passthrough literals and unknown-helper error. - setMatcherRegex / setMatcherRepeat: type and bound guards. - scopeMatchesHeading: nil-matcher branch. - protoTokenRegex: literal, {n}, {field}, and mixed forms. - Scope.Required and Repeat.Bounds: every branch. Schema-package coverage moves from 95.4% to 96.8%. https://claude.ai/code/session_012GGH62fZUzLuzP8T4ocGkJ
1 parent f3fcb1c commit 54aa006

1 file changed

Lines changed: 292 additions & 0 deletions

File tree

Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
package schema
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
"github.com/stretchr/testify/require"
8+
)
9+
10+
// TestClaimsLaterLiteral_Branches covers the four branches of
11+
// claimsLaterLiteral so an ambiguous matcher run (e.g. `regex: '.+'`
12+
// with `repeat: { min: 1 }`) yields to a later literal entry only
13+
// when one is actually present.
14+
func TestClaimsLaterLiteral_Branches(t *testing.T) {
15+
scopes := []Scope{
16+
// idx 0: preamble — never claims
17+
{Preamble: true},
18+
// idx 1: slot — never claims (no fixed identity)
19+
slotScope(),
20+
// idx 2: optional literal — present but not required, skipped
21+
optionalScope("Optional"),
22+
// idx 3: required literal that matches the heading
23+
literalScope("References"),
24+
}
25+
claimed := map[int]bool{}
26+
dh := DocHeading{Text: "References", Level: 2}
27+
assert.True(t,
28+
claimsLaterLiteral(scopes, 0, dh, claimed, nil),
29+
"a heading whose text matches a later required literal must reserve for that literal")
30+
31+
// dh that doesn't match any later literal — no claim.
32+
other := DocHeading{Text: "Trailing", Level: 2}
33+
assert.False(t,
34+
claimsLaterLiteral(scopes, 0, other, claimed, nil),
35+
"a heading that matches no later required literal does not reserve")
36+
37+
// startIdx past the literal — none ahead, no claim.
38+
assert.False(t,
39+
claimsLaterLiteral(scopes, 4, dh, claimed, nil),
40+
"no scopes at or after startIdx => no claim")
41+
42+
// Already-claimed literal — not eligible.
43+
claimed[3] = true
44+
assert.False(t,
45+
claimsLaterLiteral(scopes, 0, dh, claimed, nil),
46+
"a claimed later literal must not reserve again")
47+
}
48+
49+
// TestDisplayHeading_Branches covers all three return paths.
50+
func TestDisplayHeading_Branches(t *testing.T) {
51+
// Heading set — bare-string sugar path.
52+
assert.Equal(t, "Overview", displayHeading(literalScope("Overview")))
53+
// Heading empty, Matcher set — mapping-form fallback.
54+
sc := Scope{Matcher: &Matcher{Regex: ".+"}}
55+
assert.Equal(t, ".+", displayHeading(sc))
56+
// Neither set — preamble label.
57+
assert.Equal(t, "", displayHeading(Scope{Preamble: true}))
58+
}
59+
60+
// TestClaimRun_WrongLevelMatch covers claimRun, which fires when
61+
// matchScope sees a shallower-than-expected heading that still
62+
// matches the matcher: the level-mismatch diagnostic is appended
63+
// and the run is claimed.
64+
func TestClaimRun_WrongLevelMatch(t *testing.T) {
65+
// Nested schema: outer expects H2, inner expects H3. The doc
66+
// emits the inner heading at H2 (shallower than expected).
67+
raw := map[string]any{
68+
"sections": []any{
69+
map[string]any{
70+
"heading": "Outer",
71+
"sections": []any{
72+
map[string]any{"heading": "Inner"},
73+
},
74+
},
75+
},
76+
}
77+
sch, err := ParseInline(raw, "kind x")
78+
require.NoError(t, err)
79+
doc := newDocFile(t, "doc.md",
80+
"# T\n\n## Outer\n\n## Inner\n\nx\n")
81+
diags := Validate(doc, sch, nil, false, makeDiagForTest)
82+
var level bool
83+
for _, d := range diags {
84+
if d.Message == `heading level mismatch for "Inner": expected h3, got h2` {
85+
level = true
86+
}
87+
}
88+
assert.True(t, level, "claimRun should emit the level-mismatch diagnostic")
89+
}
90+
91+
// TestSequentialDiagMessage_NonInteger covers the parse-error path
92+
// inside sequentialDiagMessage. The `digits` helper always matches
93+
// `[0-9]+` so this never trips in normal usage; exercising it
94+
// directly keeps the safety branch covered.
95+
func TestSequentialDiagMessage_NonInteger(t *testing.T) {
96+
got := sequentialDiagMessage([]string{"abc"})
97+
assert.Contains(t, got, "must be integers")
98+
}
99+
100+
// TestSequentialDiagMessage_OK returns empty when the sequence is
101+
// strictly increasing.
102+
func TestSequentialDiagMessage_OK(t *testing.T) {
103+
assert.Empty(t, sequentialDiagMessage([]string{"1", "2", "3"}))
104+
}
105+
106+
// TestCachedMatcher_NilAndInvalid covers the two early-error
107+
// branches of cachedMatcher.
108+
func TestCachedMatcher_NilAndInvalid(t *testing.T) {
109+
_, err := cachedMatcher(nil, nil)
110+
require.Error(t, err)
111+
112+
_, err = cachedMatcher(&Matcher{Regex: "[unterminated"}, nil)
113+
require.Error(t, err)
114+
}
115+
116+
// TestMatchHeading_NilMatcher covers the early-return for a nil
117+
// matcher (the preamble's shape).
118+
func TestMatchHeading_NilMatcher(t *testing.T) {
119+
matched, captured := matchHeading(nil, DocHeading{Text: "x"}, nil)
120+
assert.False(t, matched)
121+
assert.Empty(t, captured)
122+
}
123+
124+
// TestMatchHeading_DigitsCapture verifies the captured group is
125+
// returned alongside the match.
126+
func TestMatchHeading_DigitsCapture(t *testing.T) {
127+
m := &Matcher{Regex: `Step \#(digits)`}
128+
matched, captured := matchHeading(m, DocHeading{Text: "Step 42"}, nil)
129+
assert.True(t, matched)
130+
assert.Equal(t, "42", captured)
131+
}
132+
133+
// TestFmvarLookup_MissingField returns an empty string for a
134+
// missing field, leaving the validator to flag the mismatch via
135+
// the usual missing-section diagnostic.
136+
func TestFmvarLookup_MissingField(t *testing.T) {
137+
assert.Empty(t, fmvarLookup(nil, "id"))
138+
assert.Empty(t, fmvarLookup(map[string]any{}, "id"))
139+
assert.Empty(t, fmvarLookup(map[string]any{"id": "X"}, ""))
140+
}
141+
142+
// TestResolvePattern_PassesThroughLiterals leaves a pattern without
143+
// interpolations untouched.
144+
func TestResolvePattern_PassesThroughLiterals(t *testing.T) {
145+
got, err := resolvePattern(`Step [0-9]+`, nil)
146+
require.NoError(t, err)
147+
assert.Equal(t, `Step [0-9]+`, got)
148+
}
149+
150+
// TestResolvePattern_RejectsUnknownHelper surfaces the validator's
151+
// runtime-side guard (parse-time is covered separately via
152+
// resolvePatternForCheck).
153+
func TestResolvePattern_RejectsUnknownHelper(t *testing.T) {
154+
_, err := resolvePattern(`\#(bogus)`, nil)
155+
require.Error(t, err)
156+
assert.Contains(t, err.Error(), "unknown helper")
157+
}
158+
159+
// TestCompileMatcher_NilRejected protects against a nil-pointer
160+
// crash if a caller accidentally invokes the helper without a
161+
// matcher.
162+
func TestCompileMatcher_NilRejected(t *testing.T) {
163+
_, err := compileMatcher(nil, nil)
164+
require.Error(t, err)
165+
}
166+
167+
// TestSetMatcherRegex_EmptyRejected covers the trim/empty guard.
168+
func TestSetMatcherRegex_EmptyRejected(t *testing.T) {
169+
raw := map[string]any{
170+
"sections": []any{
171+
map[string]any{"heading": map[string]any{"regex": " "}},
172+
},
173+
}
174+
_, err := ParseInline(raw, "kind x")
175+
require.Error(t, err)
176+
assert.Contains(t, err.Error(), "empty pattern")
177+
}
178+
179+
// TestSetMatcherRegex_NonStringRejected covers the type guard.
180+
func TestSetMatcherRegex_NonStringRejected(t *testing.T) {
181+
raw := map[string]any{
182+
"sections": []any{
183+
map[string]any{"heading": map[string]any{"regex": 42}},
184+
},
185+
}
186+
_, err := ParseInline(raw, "kind x")
187+
require.Error(t, err)
188+
assert.Contains(t, err.Error(), "must be a string")
189+
}
190+
191+
// TestSetMatcherRepeat_NotAMapping covers the type guard.
192+
func TestSetMatcherRepeat_NotAMapping(t *testing.T) {
193+
raw := map[string]any{
194+
"sections": []any{
195+
map[string]any{"heading": map[string]any{
196+
"regex": "X",
197+
"repeat": "nope",
198+
}},
199+
},
200+
}
201+
_, err := ParseInline(raw, "kind x")
202+
require.Error(t, err)
203+
assert.Contains(t, err.Error(), "must be a mapping")
204+
}
205+
206+
// TestSetMatcherRepeat_UnknownKey covers the unknown-key guard.
207+
func TestSetMatcherRepeat_UnknownKey(t *testing.T) {
208+
raw := map[string]any{
209+
"sections": []any{
210+
map[string]any{"heading": map[string]any{
211+
"regex": "X",
212+
"repeat": map[string]any{"bogus": 1},
213+
}},
214+
},
215+
}
216+
_, err := ParseInline(raw, "kind x")
217+
require.Error(t, err)
218+
assert.Contains(t, err.Error(), "unknown key")
219+
}
220+
221+
// TestSetMatcherRepeat_NegativeMin covers the bound-validation
222+
// path inside readIntBound.
223+
func TestSetMatcherRepeat_NegativeMin(t *testing.T) {
224+
raw := map[string]any{
225+
"sections": []any{
226+
map[string]any{"heading": map[string]any{
227+
"regex": "X",
228+
"repeat": map[string]any{"min": -1},
229+
}},
230+
},
231+
}
232+
_, err := ParseInline(raw, "kind x")
233+
require.Error(t, err)
234+
assert.Contains(t, err.Error(), "non-negative")
235+
}
236+
237+
// TestScopeMatchesHeading_NilScope returns false rather than
238+
// panicking on the nil-matcher branch.
239+
func TestScopeMatchesHeading_NilScope(t *testing.T) {
240+
assert.False(t, scopeMatchesHeading(Scope{}, DocHeading{Text: "x"}, nil))
241+
}
242+
243+
// TestScope_Required covers the three branches of Scope.Required.
244+
func TestScope_Required(t *testing.T) {
245+
// Preamble is never required.
246+
assert.False(t, Scope{Preamble: true}.Required())
247+
// Matcher absent → not required.
248+
assert.False(t, Scope{}.Required())
249+
// Optional matcher (min=0) → not required.
250+
opt := Scope{Matcher: &Matcher{
251+
Regex: "X",
252+
Repeat: Repeat{Set: true, Min: 0, Max: 1},
253+
}}
254+
assert.False(t, opt.Required())
255+
// Default cardinality (1..1) → required.
256+
assert.True(t, literalScope("X").Required())
257+
// Min=2 → required.
258+
bounded := Scope{Matcher: &Matcher{
259+
Regex: "X",
260+
Repeat: Repeat{Set: true, Min: 2, Max: 5},
261+
}}
262+
assert.True(t, bounded.Required())
263+
}
264+
265+
// TestRepeatBounds covers Repeat.Bounds and Repeat.Optional defaults.
266+
func TestRepeatBounds(t *testing.T) {
267+
// Unset → (1, 1).
268+
min, max := Repeat{}.Bounds()
269+
assert.Equal(t, 1, min)
270+
assert.Equal(t, 1, max)
271+
assert.False(t, Repeat{}.Optional())
272+
// Set with min=0 → optional.
273+
r := Repeat{Set: true, Min: 0, Max: 0}
274+
min, max = r.Bounds()
275+
assert.Equal(t, 0, min)
276+
assert.Equal(t, 0, max)
277+
assert.True(t, r.Optional())
278+
}
279+
280+
// TestProtoTokenRegex_AllTokens exercises the four desugaring
281+
// branches the proto.md parser uses.
282+
func TestProtoTokenRegex_AllTokens(t *testing.T) {
283+
// Literal text — regex-escape only.
284+
assert.Equal(t, `Step \(One\)`, protoTokenRegex(`Step (One)`))
285+
// `{n}` → digits helper.
286+
assert.Equal(t, `Step \#(digits)`, protoTokenRegex(`Step {n}`))
287+
// `{field}` → fmvar helper.
288+
assert.Equal(t, `\#(fmvar(id))`, protoTokenRegex(`{id}`))
289+
// Mixed literal + fmvar.
290+
assert.Equal(t, `\#(fmvar(id)): \#(fmvar(name))`,
291+
protoTokenRegex(`{id}: {name}`))
292+
}

0 commit comments

Comments
 (0)