Skip to content

Commit 76c192b

Browse files
[Repo Assist] test: add FsCheck property tests for NameUtils (#1696)
* test: add FsCheck property tests for NameUtils Add NameUtilsProperties.fs with 15 property-based tests covering: - nicePascalName: alphanumeric output (multi-char), uppercase-first invariant, single-PascalCase-word fixed point - niceCamelName: consistency with nicePascalName (lowercase-first), alphanumeric and lowercase-first invariants for multi-char inputs - capitalizeFirstLetter: idempotency, uppercase-first for letter inputs - uniqueGenerator: no duplicates for same-input or mixed-input sequences, first-result equals nicePascalName - trimHtml: idempotency, no-op on angle-bracket-free text, no '<' in output Test comments document edge cases (single-char passthrough, stray '>'). All 2893 Core tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * ci: trigger checks --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 1ee7eaf commit 76c192b

2 files changed

Lines changed: 230 additions & 0 deletions

File tree

tests/FSharp.Data.Core.Tests/FSharp.Data.Core.Tests.fsproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
<Compile Include="HttpRequestHeaders.fs" />
2424
<Compile Include="HttpEncodings.fs" />
2525
<Compile Include="NameUtils.fs" />
26+
<Compile Include="NameUtilsProperties.fs" />
2627
<Compile Include="Pluralizer.fs" />
2728
<Compile Include="IOTests.fs" />
2829
<Compile Include="TextConversions.fs" />
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
// Property-based tests for NameUtils using FsCheck.
2+
// Verifies structural invariants: character set, case constraints, uniqueness, and consistency.
3+
module FSharp.Data.Tests.NameUtilsProperties
4+
5+
open NUnit.Framework
6+
open System
7+
open FSharp.Data.Runtime.NameUtils
8+
open FsCheck
9+
10+
// -----------------------------------------------------------------------
11+
// Generators
12+
// -----------------------------------------------------------------------
13+
14+
/// Arbitrary non-null strings (FsCheck default can produce null on .NET).
15+
let nonNullStringArb =
16+
Arb.fromGen (Arb.generate<string> |> Gen.map (fun s -> if s = null then "" else s))
17+
18+
/// Strings with length >= 2 (single-char strings are handled specially by nicePascalName).
19+
let nonNullLongStringArb =
20+
Arb.fromGen (
21+
Arb.generate<string>
22+
|> Gen.map (fun s -> if s = null then "" else s)
23+
|> Gen.filter (fun s -> s.Length >= 2)
24+
)
25+
26+
// -----------------------------------------------------------------------
27+
// nicePascalName properties
28+
// -----------------------------------------------------------------------
29+
30+
/// For strings of length >= 2, nicePascalName filters to only alphanumeric segments.
31+
/// (Single-char strings are returned as-is via ToUpperInvariant, which may be non-alphanumeric.)
32+
[<Test>]
33+
let ``nicePascalName output contains only alphanumeric characters for multi-char inputs`` () =
34+
let prop (s: string) =
35+
let result = nicePascalName s
36+
result |> Seq.forall Char.IsLetterOrDigit
37+
38+
Check.One(
39+
{ Config.QuickThrowOnFailure with MaxTest = 2000 },
40+
Prop.forAll nonNullLongStringArb prop
41+
)
42+
43+
/// If the nicePascalName output is non-empty and derived from a multi-char input,
44+
/// the first character is always an uppercase letter or a digit.
45+
[<Test>]
46+
let ``nicePascalName non-empty output starts with uppercase letter or digit for multi-char inputs`` () =
47+
let prop (s: string) =
48+
let result = nicePascalName s
49+
result.Length = 0 || Char.IsUpper result.[0] || Char.IsDigit result.[0]
50+
51+
Check.One(
52+
{ Config.QuickThrowOnFailure with MaxTest = 2000 },
53+
Prop.forAll nonNullLongStringArb prop
54+
)
55+
56+
/// A single PascalCase word (first letter uppercase, rest lowercase, all letters) is a fixed point.
57+
[<Test>]
58+
let ``nicePascalName on a single PascalCase word is identity`` () =
59+
let pascalWordGen =
60+
gen {
61+
let! len = Gen.choose (2, 12)
62+
let! first = Gen.elements [ 'A' .. 'Z' ]
63+
let! rest = Gen.arrayOfLength (len - 1) (Gen.elements [ 'a' .. 'z' ])
64+
return String(Array.append [| first |] rest)
65+
}
66+
67+
let prop (s: string) = nicePascalName s = s
68+
69+
Check.One(
70+
{ Config.QuickThrowOnFailure with MaxTest = 1000 },
71+
Prop.forAll (Arb.fromGen pascalWordGen) prop
72+
)
73+
74+
// -----------------------------------------------------------------------
75+
// niceCamelName properties
76+
// -----------------------------------------------------------------------
77+
78+
/// niceCamelName is always defined as lowercase-first(nicePascalName), regardless of input.
79+
[<Test>]
80+
let ``niceCamelName result equals nicePascalName with lowercased first char`` () =
81+
let prop (s: string) =
82+
let s = if s = null then "" else s
83+
let camel = niceCamelName s
84+
let pascal = nicePascalName s
85+
86+
if pascal.Length = 0 then
87+
camel = ""
88+
else
89+
camel = pascal.[0].ToString().ToLowerInvariant() + pascal.Substring(1)
90+
91+
Check.One({ Config.QuickThrowOnFailure with MaxTest = 2000 }, Prop.forAll nonNullStringArb prop)
92+
93+
/// For multi-char strings, niceCamelName produces only alphanumeric characters.
94+
[<Test>]
95+
let ``niceCamelName output contains only alphanumeric characters for multi-char inputs`` () =
96+
let prop (s: string) =
97+
niceCamelName s |> Seq.forall Char.IsLetterOrDigit
98+
99+
Check.One(
100+
{ Config.QuickThrowOnFailure with MaxTest = 2000 },
101+
Prop.forAll nonNullLongStringArb prop
102+
)
103+
104+
/// For multi-char inputs, a non-empty niceCamelName output starts with a lowercase letter or digit.
105+
[<Test>]
106+
let ``niceCamelName non-empty output starts with lowercase letter or digit for multi-char inputs`` () =
107+
let prop (s: string) =
108+
let result = niceCamelName s
109+
result.Length = 0 || Char.IsLower result.[0] || Char.IsDigit result.[0]
110+
111+
Check.One(
112+
{ Config.QuickThrowOnFailure with MaxTest = 2000 },
113+
Prop.forAll nonNullLongStringArb prop
114+
)
115+
116+
// -----------------------------------------------------------------------
117+
// capitalizeFirstLetter properties
118+
// -----------------------------------------------------------------------
119+
120+
/// capitalizeFirstLetter is idempotent: applying it twice equals applying it once.
121+
[<Test>]
122+
let ``capitalizeFirstLetter is idempotent`` () =
123+
let prop (s: string) =
124+
let s = if s = null then "" else s
125+
capitalizeFirstLetter (capitalizeFirstLetter s) = capitalizeFirstLetter s
126+
127+
Check.One({ Config.QuickThrowOnFailure with MaxTest = 2000 }, Prop.forAll nonNullStringArb prop)
128+
129+
/// capitalizeFirstLetter on a letter-first string always produces an uppercase first char.
130+
[<Test>]
131+
let ``capitalizeFirstLetter non-empty output starts with an uppercase letter`` () =
132+
let letterFirstGen =
133+
gen {
134+
let! first = Gen.elements ([ 'a' .. 'z' ] @ [ 'A' .. 'Z' ])
135+
let! rest = Arb.generate<string> |> Gen.map (fun s -> if s = null then "" else s)
136+
return string first + rest
137+
}
138+
139+
let prop (s: string) =
140+
let result = capitalizeFirstLetter s
141+
Char.IsUpper result.[0]
142+
143+
Check.One(
144+
{ Config.QuickThrowOnFailure with MaxTest = 1000 },
145+
Prop.forAll (Arb.fromGen letterFirstGen) prop
146+
)
147+
148+
// -----------------------------------------------------------------------
149+
// uniqueGenerator properties
150+
// -----------------------------------------------------------------------
151+
152+
/// Repeatedly calling the generator with the same input never produces the same name twice.
153+
[<Test>]
154+
let ``uniqueGenerator never returns duplicates for repeated same-input calls`` () =
155+
let prop (count: int) =
156+
let n = (abs count % 50) + 2 // 2..51 calls
157+
let gen = uniqueGenerator nicePascalName
158+
let results = [ for _ in 1..n -> gen "name" ]
159+
results |> List.length = (results |> Set.ofList |> Set.count)
160+
161+
Check.One({ Config.QuickThrowOnFailure with MaxTest = 200 }, prop)
162+
163+
/// The generator produces unique names across a mix of different inputs.
164+
[<Test>]
165+
let ``uniqueGenerator never returns duplicates across many different inputs`` () =
166+
let inputGen =
167+
Gen.listOfLength 50 (Arb.generate<string> |> Gen.map (fun s -> if s = null then "" else s))
168+
169+
let prop (inputs: string list) =
170+
let gen = uniqueGenerator nicePascalName
171+
let results = inputs |> List.map gen
172+
results.Length = (results |> Set.ofList |> Set.count)
173+
174+
Check.One(
175+
{ Config.QuickThrowOnFailure with MaxTest = 200 },
176+
Prop.forAll (Arb.fromGen inputGen) prop
177+
)
178+
179+
/// The very first call to a fresh generator for any input returns nicePascalName of that input
180+
/// (or "Unnamed" when the name is empty).
181+
[<Test>]
182+
let ``uniqueGenerator first result for a fresh input equals nicePascalName of that input`` () =
183+
let prop (s: string) =
184+
let s = if s = null then "" else s
185+
let expected = nicePascalName s
186+
let finalExpected = if expected = "" then "Unnamed" else expected
187+
let gen = uniqueGenerator nicePascalName
188+
gen s = finalExpected
189+
190+
Check.One({ Config.QuickThrowOnFailure with MaxTest = 1000 }, Prop.forAll nonNullStringArb prop)
191+
192+
// -----------------------------------------------------------------------
193+
// trimHtml properties
194+
// -----------------------------------------------------------------------
195+
196+
/// trimHtml is idempotent: stripping tags from already-stripped text is a no-op.
197+
[<Test>]
198+
let ``trimHtml is idempotent`` () =
199+
let prop (s: string) =
200+
let s = if s = null then "" else s
201+
trimHtml (trimHtml s) = trimHtml s
202+
203+
Check.One({ Config.QuickThrowOnFailure with MaxTest = 2000 }, Prop.forAll nonNullStringArb prop)
204+
205+
/// On text with no angle brackets, trimHtml returns TrimEnd of the original.
206+
[<Test>]
207+
let ``trimHtml on plain text (no angle brackets) returns TrimEnd of original`` () =
208+
let noAngleBracketsGen =
209+
Arb.generate<string>
210+
|> Gen.map (fun s -> if s = null then "" else s)
211+
|> Gen.filter (fun s -> not (s.Contains('<')) && not (s.Contains('>')))
212+
213+
let prop (s: string) = trimHtml s = s.TrimEnd()
214+
215+
Check.One(
216+
{ Config.QuickThrowOnFailure with MaxTest = 1000 },
217+
Prop.forAll (Arb.fromGen noAngleBracketsGen) prop
218+
)
219+
220+
/// trimHtml never lets a '<' through; tags are always stripped.
221+
/// (Note: stray '>' without a matching '<' may still appear in output — that is by design.)
222+
[<Test>]
223+
let ``trimHtml output never contains opening angle brackets`` () =
224+
let prop (s: string) =
225+
let s = if s = null then "" else s
226+
not (trimHtml s |> Seq.contains '<')
227+
228+
Check.One({ Config.QuickThrowOnFailure with MaxTest = 2000 }, Prop.forAll nonNullStringArb prop)
229+

0 commit comments

Comments
 (0)