From cfea4a3e0944622d497cfefa5b775cda2d509078 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 15 Mar 2026 03:59:47 +0000 Subject: [PATCH 1/2] 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> --- .../FSharp.Data.Core.Tests.fsproj | 1 + .../NameUtilsProperties.fs | 229 ++++++++++++++++++ 2 files changed, 230 insertions(+) create mode 100644 tests/FSharp.Data.Core.Tests/NameUtilsProperties.fs diff --git a/tests/FSharp.Data.Core.Tests/FSharp.Data.Core.Tests.fsproj b/tests/FSharp.Data.Core.Tests/FSharp.Data.Core.Tests.fsproj index 8f703b3c1..1341f156a 100644 --- a/tests/FSharp.Data.Core.Tests/FSharp.Data.Core.Tests.fsproj +++ b/tests/FSharp.Data.Core.Tests/FSharp.Data.Core.Tests.fsproj @@ -23,6 +23,7 @@ + diff --git a/tests/FSharp.Data.Core.Tests/NameUtilsProperties.fs b/tests/FSharp.Data.Core.Tests/NameUtilsProperties.fs new file mode 100644 index 000000000..bcfbd1e3d --- /dev/null +++ b/tests/FSharp.Data.Core.Tests/NameUtilsProperties.fs @@ -0,0 +1,229 @@ +// Property-based tests for NameUtils using FsCheck. +// Verifies structural invariants: character set, case constraints, uniqueness, and consistency. +module FSharp.Data.Tests.NameUtilsProperties + +open NUnit.Framework +open System +open FSharp.Data.Runtime.NameUtils +open FsCheck + +// ----------------------------------------------------------------------- +// Generators +// ----------------------------------------------------------------------- + +/// Arbitrary non-null strings (FsCheck default can produce null on .NET). +let nonNullStringArb = + Arb.fromGen (Arb.generate |> Gen.map (fun s -> if s = null then "" else s)) + +/// Strings with length >= 2 (single-char strings are handled specially by nicePascalName). +let nonNullLongStringArb = + Arb.fromGen ( + Arb.generate + |> Gen.map (fun s -> if s = null then "" else s) + |> Gen.filter (fun s -> s.Length >= 2) + ) + +// ----------------------------------------------------------------------- +// nicePascalName properties +// ----------------------------------------------------------------------- + +/// For strings of length >= 2, nicePascalName filters to only alphanumeric segments. +/// (Single-char strings are returned as-is via ToUpperInvariant, which may be non-alphanumeric.) +[] +let ``nicePascalName output contains only alphanumeric characters for multi-char inputs`` () = + let prop (s: string) = + let result = nicePascalName s + result |> Seq.forall Char.IsLetterOrDigit + + Check.One( + { Config.QuickThrowOnFailure with MaxTest = 2000 }, + Prop.forAll nonNullLongStringArb prop + ) + +/// If the nicePascalName output is non-empty and derived from a multi-char input, +/// the first character is always an uppercase letter or a digit. +[] +let ``nicePascalName non-empty output starts with uppercase letter or digit for multi-char inputs`` () = + let prop (s: string) = + let result = nicePascalName s + result.Length = 0 || Char.IsUpper result.[0] || Char.IsDigit result.[0] + + Check.One( + { Config.QuickThrowOnFailure with MaxTest = 2000 }, + Prop.forAll nonNullLongStringArb prop + ) + +/// A single PascalCase word (first letter uppercase, rest lowercase, all letters) is a fixed point. +[] +let ``nicePascalName on a single PascalCase word is identity`` () = + let pascalWordGen = + gen { + let! len = Gen.choose (2, 12) + let! first = Gen.elements [ 'A' .. 'Z' ] + let! rest = Gen.arrayOfLength (len - 1) (Gen.elements [ 'a' .. 'z' ]) + return String(Array.append [| first |] rest) + } + + let prop (s: string) = nicePascalName s = s + + Check.One( + { Config.QuickThrowOnFailure with MaxTest = 1000 }, + Prop.forAll (Arb.fromGen pascalWordGen) prop + ) + +// ----------------------------------------------------------------------- +// niceCamelName properties +// ----------------------------------------------------------------------- + +/// niceCamelName is always defined as lowercase-first(nicePascalName), regardless of input. +[] +let ``niceCamelName result equals nicePascalName with lowercased first char`` () = + let prop (s: string) = + let s = if s = null then "" else s + let camel = niceCamelName s + let pascal = nicePascalName s + + if pascal.Length = 0 then + camel = "" + else + camel = pascal.[0].ToString().ToLowerInvariant() + pascal.Substring(1) + + Check.One({ Config.QuickThrowOnFailure with MaxTest = 2000 }, Prop.forAll nonNullStringArb prop) + +/// For multi-char strings, niceCamelName produces only alphanumeric characters. +[] +let ``niceCamelName output contains only alphanumeric characters for multi-char inputs`` () = + let prop (s: string) = + niceCamelName s |> Seq.forall Char.IsLetterOrDigit + + Check.One( + { Config.QuickThrowOnFailure with MaxTest = 2000 }, + Prop.forAll nonNullLongStringArb prop + ) + +/// For multi-char inputs, a non-empty niceCamelName output starts with a lowercase letter or digit. +[] +let ``niceCamelName non-empty output starts with lowercase letter or digit for multi-char inputs`` () = + let prop (s: string) = + let result = niceCamelName s + result.Length = 0 || Char.IsLower result.[0] || Char.IsDigit result.[0] + + Check.One( + { Config.QuickThrowOnFailure with MaxTest = 2000 }, + Prop.forAll nonNullLongStringArb prop + ) + +// ----------------------------------------------------------------------- +// capitalizeFirstLetter properties +// ----------------------------------------------------------------------- + +/// capitalizeFirstLetter is idempotent: applying it twice equals applying it once. +[] +let ``capitalizeFirstLetter is idempotent`` () = + let prop (s: string) = + let s = if s = null then "" else s + capitalizeFirstLetter (capitalizeFirstLetter s) = capitalizeFirstLetter s + + Check.One({ Config.QuickThrowOnFailure with MaxTest = 2000 }, Prop.forAll nonNullStringArb prop) + +/// capitalizeFirstLetter on a letter-first string always produces an uppercase first char. +[] +let ``capitalizeFirstLetter non-empty output starts with an uppercase letter`` () = + let letterFirstGen = + gen { + let! first = Gen.elements ([ 'a' .. 'z' ] @ [ 'A' .. 'Z' ]) + let! rest = Arb.generate |> Gen.map (fun s -> if s = null then "" else s) + return string first + rest + } + + let prop (s: string) = + let result = capitalizeFirstLetter s + Char.IsUpper result.[0] + + Check.One( + { Config.QuickThrowOnFailure with MaxTest = 1000 }, + Prop.forAll (Arb.fromGen letterFirstGen) prop + ) + +// ----------------------------------------------------------------------- +// uniqueGenerator properties +// ----------------------------------------------------------------------- + +/// Repeatedly calling the generator with the same input never produces the same name twice. +[] +let ``uniqueGenerator never returns duplicates for repeated same-input calls`` () = + let prop (count: int) = + let n = (abs count % 50) + 2 // 2..51 calls + let gen = uniqueGenerator nicePascalName + let results = [ for _ in 1..n -> gen "name" ] + results |> List.length = (results |> Set.ofList |> Set.count) + + Check.One({ Config.QuickThrowOnFailure with MaxTest = 200 }, prop) + +/// The generator produces unique names across a mix of different inputs. +[] +let ``uniqueGenerator never returns duplicates across many different inputs`` () = + let inputGen = + Gen.listOfLength 50 (Arb.generate |> Gen.map (fun s -> if s = null then "" else s)) + + let prop (inputs: string list) = + let gen = uniqueGenerator nicePascalName + let results = inputs |> List.map gen + results.Length = (results |> Set.ofList |> Set.count) + + Check.One( + { Config.QuickThrowOnFailure with MaxTest = 200 }, + Prop.forAll (Arb.fromGen inputGen) prop + ) + +/// The very first call to a fresh generator for any input returns nicePascalName of that input +/// (or "Unnamed" when the name is empty). +[] +let ``uniqueGenerator first result for a fresh input equals nicePascalName of that input`` () = + let prop (s: string) = + let s = if s = null then "" else s + let expected = nicePascalName s + let finalExpected = if expected = "" then "Unnamed" else expected + let gen = uniqueGenerator nicePascalName + gen s = finalExpected + + Check.One({ Config.QuickThrowOnFailure with MaxTest = 1000 }, Prop.forAll nonNullStringArb prop) + +// ----------------------------------------------------------------------- +// trimHtml properties +// ----------------------------------------------------------------------- + +/// trimHtml is idempotent: stripping tags from already-stripped text is a no-op. +[] +let ``trimHtml is idempotent`` () = + let prop (s: string) = + let s = if s = null then "" else s + trimHtml (trimHtml s) = trimHtml s + + Check.One({ Config.QuickThrowOnFailure with MaxTest = 2000 }, Prop.forAll nonNullStringArb prop) + +/// On text with no angle brackets, trimHtml returns TrimEnd of the original. +[] +let ``trimHtml on plain text (no angle brackets) returns TrimEnd of original`` () = + let noAngleBracketsGen = + Arb.generate + |> Gen.map (fun s -> if s = null then "" else s) + |> Gen.filter (fun s -> not (s.Contains('<')) && not (s.Contains('>'))) + + let prop (s: string) = trimHtml s = s.TrimEnd() + + Check.One( + { Config.QuickThrowOnFailure with MaxTest = 1000 }, + Prop.forAll (Arb.fromGen noAngleBracketsGen) prop + ) + +/// trimHtml never lets a '<' through; tags are always stripped. +/// (Note: stray '>' without a matching '<' may still appear in output — that is by design.) +[] +let ``trimHtml output never contains opening angle brackets`` () = + let prop (s: string) = + let s = if s = null then "" else s + not (trimHtml s |> Seq.contains '<') + + Check.One({ Config.QuickThrowOnFailure with MaxTest = 2000 }, Prop.forAll nonNullStringArb prop) + From 8e77d6fbdc17928afae4c197ae3a1c25042077fa Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 15 Mar 2026 04:04:30 +0000 Subject: [PATCH 2/2] ci: trigger checks