From d4c5516775c84fe180ef553b864d6354e79d81d9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 17 Apr 2026 04:48:59 +0000 Subject: [PATCH] test+eng: 37 StructuralInference unit tests; CI reads SDK version from global.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 9 — Testing Improvements: Add tests/FSharp.Data.Core.Tests/StructuralInference.fs with 37 unit tests covering the core type-inference module (StructuralInference.fs, the largest source file at 777 lines) which previously had no dedicated test file. Tests cover: - typeTag (all InferedType cases incl. NET6+ DateOnly/TimeOnly) - subtypeInfered: Top/Null merging, numeric widening hierarchy (Bit0+Bit1→Bit, int+int64→int64, int+float→float, decimal+float→float, Bit0+bool→bool), incompatible types → Heterogeneous, optionality - subtypeInfered with Records: field union, different names → Heterogeneous - inferCollectionType: single type, mixed numeric widening, multiplicity - getInferedTypeFromString: empty→Null, Bit/bool/int/int64/decimal/string/ Guid inference, NoInference mode - InferenceMode'.FromPublicApi: all enum values - supportsUnitsOfMeasure: numeric vs non-numeric types All 2957 tests pass (was 2920 before; +37 new). Task 4 — Engineering Investments: Replace hardcoded dotnet-version: 10.0.201 in pull-requests.yml (×3) and push-master.yml (×1) with dotnet-version-file: global.json. The SDK version is now a single source of truth in global.json (which already has rollForward: latestMinor). Also update step names from 'Setup .NET 10' to 'Setup .NET' so they stay accurate after future SDK bumps. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/pull-requests.yml | 12 +- .github/workflows/push-master.yml | 4 +- .../FSharp.Data.Core.Tests.fsproj | 1 + .../StructuralInference.fs | 354 ++++++++++++++++++ 4 files changed, 363 insertions(+), 8 deletions(-) create mode 100644 tests/FSharp.Data.Core.Tests/StructuralInference.fs diff --git a/.github/workflows/pull-requests.yml b/.github/workflows/pull-requests.yml index 31d47ece3..9769c5425 100644 --- a/.github/workflows/pull-requests.yml +++ b/.github/workflows/pull-requests.yml @@ -13,10 +13,10 @@ jobs: timeout-minutes: 10 steps: - uses: actions/checkout@v6 - - name: Setup .NET 10 + - name: Setup .NET uses: actions/setup-dotnet@v5 with: - dotnet-version: 10.0.201 + dotnet-version-file: global.json - name: Cache NuGet packages uses: actions/cache@v5 with: @@ -37,10 +37,10 @@ jobs: timeout-minutes: 40 steps: - uses: actions/checkout@v6 - - name: Setup .NET 10 + - name: Setup .NET uses: actions/setup-dotnet@v5 with: - dotnet-version: 10.0.201 + dotnet-version-file: global.json - name: Cache NuGet packages uses: actions/cache@v5 with: @@ -63,10 +63,10 @@ jobs: timeout-minutes: 40 steps: - uses: actions/checkout@v6 - - name: Setup .NET 10 + - name: Setup .NET uses: actions/setup-dotnet@v5 with: - dotnet-version: 10.0.201 + dotnet-version-file: global.json - name: Cache NuGet packages uses: actions/cache@v5 with: diff --git a/.github/workflows/push-master.yml b/.github/workflows/push-master.yml index bdad12347..64e097f2d 100644 --- a/.github/workflows/push-master.yml +++ b/.github/workflows/push-master.yml @@ -17,10 +17,10 @@ jobs: steps: - uses: actions/checkout@v6 - - name: Setup .NET 10 + - name: Setup .NET uses: actions/setup-dotnet@v5 with: - dotnet-version: 10.0.201 + dotnet-version-file: global.json - name: Cache NuGet packages uses: actions/cache@v5 with: 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 1341f156a..dc1bf9cd1 100644 --- a/tests/FSharp.Data.Core.Tests/FSharp.Data.Core.Tests.fsproj +++ b/tests/FSharp.Data.Core.Tests/FSharp.Data.Core.Tests.fsproj @@ -44,6 +44,7 @@ + diff --git a/tests/FSharp.Data.Core.Tests/StructuralInference.fs b/tests/FSharp.Data.Core.Tests/StructuralInference.fs new file mode 100644 index 000000000..a9567ebf6 --- /dev/null +++ b/tests/FSharp.Data.Core.Tests/StructuralInference.fs @@ -0,0 +1,354 @@ +module FSharp.Data.Tests.StructuralInference + +open System +open System.Globalization +open NUnit.Framework +open FsUnit +open FSharp.Data +open FSharp.Data.Runtime.StructuralTypes +open FSharp.Data.Runtime.StructuralInference + +// Helpers + +let private culture = CultureInfo.InvariantCulture +let private uomProvider = defaultUnitsOfMeasureProvider +let private valuesOnly = InferenceMode'.ValuesOnly +let private noInference = InferenceMode'.NoInference + +let private prim typ = InferedType.Primitive(typ, None, false, false) +let private primOpt typ = InferedType.Primitive(typ, None, true, false) + +let private inferStr value = + getInferedTypeFromString uomProvider valuesOnly culture value None + +// ─── typeTag ──────────────────────────────────────────────────────────────── + +[] +let ``typeTag returns Number for numeric primitives`` () = + typeTag (prim typeof) |> should equal InferedTypeTag.Number + typeTag (prim typeof) |> should equal InferedTypeTag.Number + typeTag (prim typeof) |> should equal InferedTypeTag.Number + typeTag (prim typeof) |> should equal InferedTypeTag.Number + typeTag (prim typeof) |> should equal InferedTypeTag.Number + typeTag (prim typeof) |> should equal InferedTypeTag.Number + typeTag (prim typeof) |> should equal InferedTypeTag.Number + +[] +let ``typeTag returns correct tag for other primitive types`` () = + typeTag (prim typeof) |> should equal InferedTypeTag.Boolean + typeTag (prim typeof) |> should equal InferedTypeTag.String + typeTag (prim typeof) |> should equal InferedTypeTag.DateTime + typeTag (prim typeof) |> should equal InferedTypeTag.DateTime + typeTag (prim typeof) |> should equal InferedTypeTag.TimeSpan + typeTag (prim typeof) |> should equal InferedTypeTag.Guid + +[] +let ``typeTag returns Null for Null and Top`` () = + typeTag InferedType.Null |> should equal InferedTypeTag.Null + typeTag InferedType.Top |> should equal InferedTypeTag.Null + +[] +let ``typeTag returns correct tag for record, collection, heterogeneous, json`` () = + typeTag (InferedType.Record(Some "Foo", [], false)) |> should equal (InferedTypeTag.Record(Some "Foo")) + typeTag (InferedType.Record(None, [], false)) |> should equal (InferedTypeTag.Record None) + typeTag (InferedType.Collection([], Map.empty)) |> should equal InferedTypeTag.Collection + typeTag (InferedType.Heterogeneous(Map.empty, false)) |> should equal InferedTypeTag.Heterogeneous + typeTag (InferedType.Json(prim typeof, false)) |> should equal InferedTypeTag.Json + +#if NET6_0_OR_GREATER +[] +let ``typeTag returns DateOnly and TimeOnly on NET6+`` () = + typeTag (prim typeof) |> should equal InferedTypeTag.DateOnly + typeTag (prim typeof) |> should equal InferedTypeTag.TimeOnly +#endif + +// ─── subtypeInfered: Top and Null ─────────────────────────────────────────── + +[] +let ``subtypeInfered: Top merged with any type returns that type`` () = + subtypeInfered false InferedType.Top (prim typeof) |> should equal (prim typeof) + subtypeInfered false (prim typeof) InferedType.Top |> should equal (prim typeof) + subtypeInfered false InferedType.Top InferedType.Null |> should equal InferedType.Null + subtypeInfered false InferedType.Top InferedType.Top |> should equal InferedType.Top + +[] +let ``subtypeInfered: Null merged with string makes it optional`` () = + // string can have empty values so with allowEmptyValues=true, string stays non-optional + let result = subtypeInfered true InferedType.Null (prim typeof) + + match result with + | InferedType.Primitive(typ, None, false, false) when typ = typeof -> () // stays non-optional + | _ -> failwithf "Unexpected result: %A" result + +[] +let ``subtypeInfered: Null merged with int makes it optional`` () = + // int cannot have empty values so it becomes optional regardless of allowEmptyValues + let result = subtypeInfered false InferedType.Null (prim typeof) + + match result with + | InferedType.Primitive(typ, None, true, false) when typ = typeof -> () + | _ -> failwithf "Unexpected result: %A" result + +// ─── subtypeInfered: primitives ───────────────────────────────────────────── + +[] +let ``subtypeInfered: identical primitives return same primitive`` () = + subtypeInfered false (prim typeof) (prim typeof) |> should equal (prim typeof) + subtypeInfered false (prim typeof) (prim typeof) |> should equal (prim typeof) + subtypeInfered false (prim typeof) (prim typeof) |> should equal (prim typeof) + +[] +let ``subtypeInfered: numeric widening Bit0 + Bit1 → Bit`` () = + let result = subtypeInfered false (prim typeof) (prim typeof) + + match result with + | InferedType.Primitive(typ, None, false, false) when typ = typeof -> () + | _ -> failwithf "Unexpected result: %A" result + +[] +let ``subtypeInfered: numeric widening int + int64 → int64`` () = + let result = subtypeInfered false (prim typeof) (prim typeof) + + match result with + | InferedType.Primitive(typ, None, false, false) when typ = typeof -> () + | _ -> failwithf "Unexpected result: %A" result + +[] +let ``subtypeInfered: numeric widening int + float → float`` () = + let result = subtypeInfered false (prim typeof) (prim typeof) + + match result with + | InferedType.Primitive(typ, None, false, false) when typ = typeof -> () + | _ -> failwithf "Unexpected result: %A" result + +[] +let ``subtypeInfered: numeric widening decimal + float → float`` () = + let result = subtypeInfered false (prim typeof) (prim typeof) + + match result with + | InferedType.Primitive(typ, None, false, false) when typ = typeof -> () + | _ -> failwithf "Unexpected result: %A" result + +[] +let ``subtypeInfered: Bit0 + bool → bool`` () = + // bool ⊇ Bit0 (bool has Bit0 in conversion table) + let result = subtypeInfered false (prim typeof) (prim typeof) + + match result with + | InferedType.Primitive(typ, None, false, false) when typ = typeof -> () + | _ -> failwithf "Unexpected result: %A" result + +[] +let ``subtypeInfered: incompatible types create Heterogeneous`` () = + let result = subtypeInfered false (prim typeof) (prim typeof) + + match result with + | InferedType.Heterogeneous(map, false) -> + map |> Map.containsKey InferedTypeTag.String |> should equal true + map |> Map.containsKey InferedTypeTag.Number |> should equal true + | _ -> failwithf "Unexpected result: %A" result + +[] +let ``subtypeInfered: Guid + string creates Heterogeneous`` () = + let result = subtypeInfered false (prim typeof) (prim typeof) + + match result with + | InferedType.Heterogeneous(map, false) -> + map |> Map.containsKey InferedTypeTag.Guid |> should equal true + map |> Map.containsKey InferedTypeTag.String |> should equal true + | _ -> failwithf "Unexpected result: %A" result + +[] +let ``subtypeInfered: optionality is preserved when merging`` () = + // Required int merged with required int → required int + subtypeInfered false (prim typeof) (prim typeof) |> should equal (prim typeof) + + // Optional int merged with required int → optional int + let result = subtypeInfered false (primOpt typeof) (prim typeof) + + match result with + | InferedType.Primitive(typ, None, true, false) when typ = typeof -> () + | _ -> failwithf "Unexpected result: %A" result + +// ─── subtypeInfered: records ──────────────────────────────────────────────── + +[] +let ``subtypeInfered: records with same name union their fields`` () = + let record1 = + InferedType.Record( + Some "R", + [ { Name = "a"; Type = prim typeof } + { Name = "b"; Type = prim typeof } ], + false + ) + + let record2 = + InferedType.Record( + Some "R", + [ { Name = "a"; Type = prim typeof } + { Name = "c"; Type = prim typeof } ], + false + ) + + let result = subtypeInfered false record1 record2 + + match result with + | InferedType.Record(Some "R", fields, false) -> + fields |> List.map (fun f -> f.Name) |> List.sort |> should equal [ "a"; "b"; "c" ] + // 'b' is in record1 only → optional in result + let bField = fields |> List.find (fun f -> f.Name = "b") + + match bField.Type with + | InferedType.Primitive(_, _, true, _) -> () // optional + | _ -> failwithf "'b' should be optional, got %A" bField.Type + | _ -> failwithf "Unexpected result: %A" result + +[] +let ``subtypeInfered: records with different names become Heterogeneous`` () = + let record1 = InferedType.Record(Some "R1", [], false) + let record2 = InferedType.Record(Some "R2", [], false) + let result = subtypeInfered false record1 record2 + + match result with + | InferedType.Heterogeneous _ -> () + | _ -> failwithf "Expected Heterogeneous, got %A" result + +// ─── inferCollectionType ──────────────────────────────────────────────────── + +[] +let ``inferCollectionType with single int type gives Collection with Number`` () = + let result = inferCollectionType false [ prim typeof ] + + match result with + | InferedType.Collection(order, map) -> + order |> should contain InferedTypeTag.Number + map |> Map.containsKey InferedTypeTag.Number |> should equal true + | _ -> failwithf "Unexpected result: %A" result + +[] +let ``inferCollectionType with mixed numeric types widens to float`` () = + let result = + inferCollectionType false [ prim typeof; prim typeof; prim typeof ] + + match result with + | InferedType.Collection(_, map) -> + let _, elemType = map |> Map.find InferedTypeTag.Number + + match elemType with + | InferedType.Primitive(typ, None, false, false) when typ = typeof -> () + | _ -> failwithf "Expected float element type, got %A" elemType + | _ -> failwithf "Unexpected result: %A" result + +[] +let ``inferCollectionType with multiple items of same type gives Multiple multiplicity`` () = + let result = + inferCollectionType false [ prim typeof; prim typeof; prim typeof ] + + match result with + | InferedType.Collection(_, map) -> + let mult, _ = map |> Map.find InferedTypeTag.Number + mult |> should equal InferedMultiplicity.Multiple + | _ -> failwithf "Unexpected result: %A" result + +[] +let ``inferCollectionType with single item gives Single multiplicity`` () = + let result = inferCollectionType false [ prim typeof ] + + match result with + | InferedType.Collection(_, map) -> + let mult, _ = map |> Map.find InferedTypeTag.Number + mult |> should equal InferedMultiplicity.Single + | _ -> failwithf "Unexpected result: %A" result + +// ─── getInferedTypeFromString ──────────────────────────────────────────────── + +[] +let ``getInferedTypeFromString: empty string returns Null`` () = + inferStr "" |> should equal InferedType.Null + +[] +let ``getInferedTypeFromString: "0" and "1" infer Bit types`` () = + inferStr "0" |> should equal (prim typeof) + inferStr "1" |> should equal (prim typeof) + +[] +let ``getInferedTypeFromString: boolean strings infer bool`` () = + inferStr "true" |> should equal (prim typeof) + inferStr "false" |> should equal (prim typeof) + inferStr "True" |> should equal (prim typeof) + inferStr "yes" |> should equal (prim typeof) + inferStr "no" |> should equal (prim typeof) + +[] +let ``getInferedTypeFromString: integer strings infer int`` () = + inferStr "42" |> should equal (prim typeof) + inferStr "-123" |> should equal (prim typeof) + inferStr "2147483647" |> should equal (prim typeof) // Int32.MaxValue + +[] +let ``getInferedTypeFromString: large integer strings infer int64`` () = + inferStr "2147483648" |> should equal (prim typeof) // Int32.MaxValue + 1 + inferStr "9999999999" |> should equal (prim typeof) + +[] +let ``getInferedTypeFromString: decimal strings infer decimal`` () = + inferStr "3.14" |> should equal (prim typeof) + inferStr "-0.5" |> should equal (prim typeof) + +[] +let ``getInferedTypeFromString: unrecognised strings fall back to string`` () = + inferStr "hello" |> should equal (prim typeof) + inferStr "not-a-number" |> should equal (prim typeof) + inferStr "abc123" |> should equal (prim typeof) + +[] +let ``getInferedTypeFromString: GUID strings infer Guid`` () = + inferStr "6F9619FF-8B86-D011-B42D-00C04FC964FF" |> should equal (prim typeof) + +[] +let ``getInferedTypeFromString: NoInference mode always returns string`` () = + let inferNoInference value = + getInferedTypeFromString uomProvider noInference culture value None + + inferNoInference "42" |> should equal (prim typeof) + inferNoInference "true" |> should equal (prim typeof) + inferNoInference "3.14" |> should equal (prim typeof) + +// ─── InferenceMode' ───────────────────────────────────────────────────────── + +[] +let ``InferenceMode FromPublicApi BackwardCompatible with legacyInferTypesFromValues=true → ValuesOnly`` () = + InferenceMode'.FromPublicApi(InferenceMode.BackwardCompatible, true) + |> should equal InferenceMode'.ValuesOnly + +[] +let ``InferenceMode FromPublicApi BackwardCompatible with legacyInferTypesFromValues=false → NoInference`` () = + InferenceMode'.FromPublicApi(InferenceMode.BackwardCompatible, false) + |> should equal InferenceMode'.NoInference + +[] +let ``InferenceMode FromPublicApi explicit modes map correctly`` () = + InferenceMode'.FromPublicApi(InferenceMode.NoInference) |> should equal InferenceMode'.NoInference + InferenceMode'.FromPublicApi(InferenceMode.ValuesOnly) |> should equal InferenceMode'.ValuesOnly + + InferenceMode'.FromPublicApi(InferenceMode.ValuesAndInlineSchemasHints) + |> should equal InferenceMode'.ValuesAndInlineSchemasHints + + InferenceMode'.FromPublicApi(InferenceMode.ValuesAndInlineSchemasOverrides) + |> should equal InferenceMode'.ValuesAndInlineSchemasOverrides + +// ─── supportsUnitsOfMeasure ────────────────────────────────────────────────── + +[] +let ``supportsUnitsOfMeasure returns true for numeric types`` () = + supportsUnitsOfMeasure typeof |> should equal true + supportsUnitsOfMeasure typeof |> should equal true + supportsUnitsOfMeasure typeof |> should equal true + supportsUnitsOfMeasure typeof |> should equal true + +[] +let ``supportsUnitsOfMeasure returns false for non-numeric types`` () = + supportsUnitsOfMeasure typeof |> should equal false + supportsUnitsOfMeasure typeof |> should equal false + supportsUnitsOfMeasure typeof |> should equal false + supportsUnitsOfMeasure typeof |> should equal false