diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 316a54bf..667c4eec 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -37,6 +37,9 @@ module's `.fsi` and the `.fs` on its `.fs`, so that Visual Studio shows them as │ │ ├── FlatListTuple.fs / FlatListTuple.fsi – Tuple namespace overrides │ │ ├── FlatListBuilder.fs / FlatListBuilder.fsi │ │ └── FlatListCE.fs / FlatListCE.fsi +│ ├── EquatableArray/ – ImmutableArray with value semantics, JSON converter, patterns +│ │ ├── EquatableArray.fs / EquatableArray.fsi +│ │ └── EquatableArrayPatterns.fs / EquatableArrayPatterns.fsi │ ├── IndexedList/ – ImmutableList API surface and builders │ │ ├── IndexedListTypes.fs │ │ ├── IndexedList.fs / IndexedList.fsi @@ -116,6 +119,7 @@ module's `.fsi` and the `.fs` on its `.fs`, so that Visual Studio shows them as │ │ ├── FrozenSetTuple.fs / FrozenSetTuple.fsi │ │ └── FrozenSetCE.fs / FrozenSetCE.fsi │ └── ComputationExpressions.fs / .fsi – the computation expression instances +├── tests/FSharp.Collections.Immutable.CSharp.Tests/ – MSTest project in C#: collection expressions, ranges, operators └── tests/FSharp.Collections.Immutable.Tests/ – MSTest test project ├── TestCategories.fs – centralized custom test category attributes ├── TestCategoriesTests.fs – enforces the test category rules @@ -149,8 +153,10 @@ The modules follow ADR 0001 – Per-collection modules (`docsSrc/Explanations/AD * [`System.Collections.Immutable`](https://www.nuget.org/packages/System.Collections.Immutable/) – immutable collections foundation * [`FSharp.Core`](https://www.nuget.org/packages/FSharp.Core/) – F# language core library +* [`System.Text.Json`](https://learn.microsoft.com/dotnet/standard/serialization/system-text-json/overview) – the JSON converter of `EquatableArray` (shared framework, no package) * [`MSTest`](https://github.com/microsoft/testfx) – test framework * [`Unquote`](https://github.com/SwensenSoftware/unquote) – expressive assertions for complex checks +* [`FSharp.SystemTextJson`](https://github.com/Tarmil/FSharp.SystemTextJson) – JSON tests with F# records and unions * [`FAKE`](https://fake.build/) – build and release scripting * [`Argu`](https://github.com/fsprojects/Argu) – command-line parsing for build tooling diff --git a/CHANGELOG.md b/CHANGELOG.md index 8373ad55..a766df38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 semantics and exceptions. Its `tryFind`, `tryFindIndex` and `tryPick` return `voption`, `choose` and `tryPick` take functions that return `voption`, and `groupBy`, `partition`, `partitionWith` and `zip` return struct tuples; the `Parallel` modules of the `Option` and `Tuple` namespaces repeat them with options and reference tuples. +* `EquatableArray<'T>`: a struct over one `ImmutableArray<'T>` with value semantics, for fields of records and unions, + dictionary and set keys and persisted documents (ADR 0002) + * Equality and the hash code use all elements; every caller gets them, C# `==` and `!=` included + * Comparison puts shorter arrays first, then orders by the first different element, in the order of F# comparison + * The default value is the empty array + * Conversion to and from `ImmutableArray<'T>` does not copy; C# collection expressions and ranges work + * A System.Text.Json converter that needs no registration + * The `EquatableArray` module: `empty`, `ofArray`, `ofImmutableArray`, `ofList`, `ofSeq`, `singleton`, + `unsafeOfArray`, `isEmpty`, `length`, `map`, `toImmutableArray` + * The struct partial active patterns `Arr0`, `Arr1`, `Arr2`, `Arr3` and `ArrN` ### Changed diff --git a/Directory.Packages.props b/Directory.Packages.props index def08789..8e238ee7 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -24,6 +24,7 @@ + diff --git a/FSharp.Collections.Immutable.slnf b/FSharp.Collections.Immutable.slnf index 097e0fa8..85556a30 100644 --- a/FSharp.Collections.Immutable.slnf +++ b/FSharp.Collections.Immutable.slnf @@ -3,6 +3,7 @@ "path": "FSharp.Collections.Immutable.slnx", "projects": [ "src\\FSharp.Collections.Immutable\\FSharp.Collections.Immutable.fsproj", + "tests\\FSharp.Collections.Immutable.CSharp.Tests\\FSharp.Collections.Immutable.CSharp.Tests.csproj", "tests\\FSharp.Collections.Immutable.Tests\\FSharp.Collections.Immutable.Tests.fsproj" ] } diff --git a/FSharp.Collections.Immutable.slnx b/FSharp.Collections.Immutable.slnx index b2ce8282..803499e4 100644 --- a/FSharp.Collections.Immutable.slnx +++ b/FSharp.Collections.Immutable.slnx @@ -13,6 +13,7 @@ + diff --git a/README.md b/README.md index 950057e1..d7a66f81 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ Please also join the [F# Open Source Group](http://fsharp.github.com) | Module | .NET type | Computation expression | |---|---|---| | `FlatList` | `ImmutableArray<'T>` | `flatList { }` | +| `EquatableArray` | `EquatableArray<'T>`, an `ImmutableArray<'T>` with value equality, comparison and an empty default value | – | | `ImmutableList` | `ImmutableList<'T>` | – | | `Stack` | `ImmutableStack<'T>` | – | | `Queue` | `ImmutableQueue<'T>` | – | diff --git a/docsSrc/Explanations/Background.md b/docsSrc/Explanations/Background.md index 822820fa..d7f6b725 100644 --- a/docsSrc/Explanations/Background.md +++ b/docsSrc/Explanations/Background.md @@ -27,6 +27,7 @@ While F# has its own built-in immutable collections, System.Collections.Immutabl | Module | .NET type | Computation expression | |---|---|---| | `FlatList` | `ImmutableArray<'T>` | `flatList { }` | +| `EquatableArray` | `EquatableArray<'T>`, an `ImmutableArray<'T>` with value equality, comparison and an empty default value | – | | `ImmutableList` | `ImmutableList<'T>` | – | | `Stack` | `ImmutableStack<'T>` | – | | `Queue` | `ImmutableQueue<'T>` | – | diff --git a/docsSrc/index.md b/docsSrc/index.md index 276ad8fe..3bb669d1 100644 --- a/docsSrc/index.md +++ b/docsSrc/index.md @@ -9,6 +9,7 @@ F# bindings for [System.Collections.Immutable](https://learn.microsoft.com/en-us | Module | .NET type | Computation expression | |---|---|---| | `FlatList` | `ImmutableArray<'T>` | `flatList { }` | +| `EquatableArray` | `EquatableArray<'T>`, an `ImmutableArray<'T>` with value equality, comparison and an empty default value | – | | `ImmutableList` | `ImmutableList<'T>` | – | | `Stack` | `ImmutableStack<'T>` | – | | `Queue` | `ImmutableQueue<'T>` | – | diff --git a/src/FSharp.Collections.Immutable/EquatableArray/EquatableArray.fs b/src/FSharp.Collections.Immutable/EquatableArray/EquatableArray.fs new file mode 100644 index 00000000..5eed1dfe --- /dev/null +++ b/src/FSharp.Collections.Immutable/EquatableArray/EquatableArray.fs @@ -0,0 +1,354 @@ +namespace FSharp.Collections.Immutable + +open System +open System.Collections +open System.Collections.Generic +open System.Collections.Immutable +open System.Runtime.CompilerServices +open System.Runtime.InteropServices +open System.Text.Json +open System.Text.Json.Serialization + +/// The comparer of the elements of an EquatableArray, chosen once per element type: the order of F# generic +/// comparison, without boxing where a BCL comparer has that order. +[] +type internal ElementComparer<'T> private () = + + static let comparer : IComparer<'T> = + let elementType = typeof<'T> + + if Type.(=) (elementType, typeof) then + // Comparer.Default compares with the current culture; F# compares strings ordinally + unbox>(box StringComparer.Ordinal) + elif + elementType.IsPrimitive + || Type.(=) (elementType, typeof) + then + // On these types Comparer<'T>.Default agrees with F# compare (NaN, signed zero and the extremes included) + // and does not box + Comparer<'T>.Default + else + // Unchecked.compare needs no 'comparison' constraint, which a conditional constraint cannot give + { + new IComparer<'T> with + member _.Compare (x, y) = Unchecked.compare x y + } + + static member Instance = comparer + +/// The nesting of EquatableArray displays on the current thread. %A starts a new formatter for each nested display, so +/// its own depth limit and cycle check do not reach through them; this counter does. +[] +type internal DisplayDepth private () = + + [] + static val mutable private depth : int + + /// The nesting at which a display shows "...", as %A does at its own default depth limit. + static member Limit = 100 + + static member Current = DisplayDepth.depth + static member Enter () = DisplayDepth.depth <- DisplayDepth.depth + 1 + static member Exit () = DisplayDepth.depth <- DisplayDepth.depth - 1 + +[] +[, "Create")>] +[)>] +[] +type EquatableArray<[] 'T> = + + // The only field, so that the wrapper is as large as one reference + val private array : ImmutableArray<'T> + + new (items : ImmutableArray<'T>) = { array = items } + + // A default field comes from Unchecked.defaultof, Array.zeroCreate or a missing JSON member. Every member reads + // the elements through Items, so the default state of ImmutableArray is never observable. + member this.Items : ImmutableArray<'T> = + if this.array.IsDefault then + ImmutableArray<'T>.Empty + else + this.array + + member this.Length : int = if this.array.IsDefault then 0 else this.array.Length + member this.IsEmpty : bool = this.array.IsDefaultOrEmpty + + member this.Item + with get (index : int) : 'T = this.Items[index] + + member this.GetEnumerator () : ImmutableArray<'T>.Enumerator = this.Items.GetEnumerator () + member this.AsSpan () : ReadOnlySpan<'T> = this.Items.AsSpan () + member this.AsMemory () : ReadOnlyMemory<'T> = this.Items.AsMemory () + + member this.GetSlice (start : int option, finish : int option) : EquatableArray<'T> = + let items = this.Items + // The bounds of F# array slices: a missing or negative start means the first element, a missing or too large + // finish means the last element, and an empty range gives an empty array + let low = + match start with + | Some index when index >= 0 -> index + | _ -> 0 + + let high = + match finish with + | Some index when index < items.Length -> index + | _ -> items.Length - 1 + + // Compare before subtracting: high - low + 1 overflows for extreme bounds such as Int32.MinValue + if high < low then + EquatableArray<'T> ImmutableArray<'T>.Empty + else + // Both bounds are inside the array here. ImmutableArray.Slice returns the same array for the whole range + // and copies any other range. + EquatableArray<'T>(items.Slice (low, high - low + 1)) + + // The member that C# ranges call. It checks its arguments as ImmutableArray.Slice does. + member this.Slice (start : int, length : int) : EquatableArray<'T> = EquatableArray<'T>(this.Items.Slice (start, length)) + + member this.Equals (other : EquatableArray<'T>) : bool = + let left = this.Items + let right = other.Items + // The same underlying array is equal to itself without a scan, because EqualityComparer<'T>.Default is + // reflexive. SequenceEqual compares the lengths first and does not box the elements. + left.Equals right + || MemoryExtensions.SequenceEqual (left.AsSpan (), right.AsSpan (), EqualityComparer<'T>.Default) + + override this.Equals (other : objnull) : bool = + match other with + | :? EquatableArray<'T> as other -> this.Equals other + | _ -> false + + override this.GetHashCode () : int = + // All elements, not the last 8 as the structural hash of ImmutableArray + let mutable hash = HashCode () + + for item in this.Items.AsSpan () do + hash.Add item + + hash.ToHashCode () + + member this.CompareTo (other : EquatableArray<'T>) : int = + let left = this.Items + let right = other.Items + + // Length first, as F# compares arrays: arrays of different lengths are ordered without reading an element + if left.Length <> right.Length then + left.Length.CompareTo right.Length + else + let comparer = ElementComparer<'T>.Instance + let mutable result = 0 + let mutable index = 0 + + while result = 0 && index < left.Length do + result <- comparer.Compare (left[index], right[index]) + index <- index + 1 + + result + + // A text, not a list: a value with StructuredFormatDisplay nested in the display of another one prints as "...", + // which would hide the children of a syntax tree. %A reads this property through reflection, also when it is + // private. + member private this.StructuredDisplayText : string = + // Each nested display starts a new %A formatter, which resets its depth limit and cycle check. Without a shared + // guard, a cycle through a mutable record or a very deep tree recurses until the stack overflows. The guard + // shows "..." at its limit, as %A does; the stack check also protects a thread with a small stack. + if + DisplayDepth.Current >= DisplayDepth.Limit + || not (RuntimeHelpers.TryEnsureSufficientExecutionStack ()) + then + "..." + else + DisplayDepth.Enter () + + try + // Items is never a default array, so its underlying array is never null + let items = + this.Items + |> ImmutableCollectionsMarshal.AsArray + |> Unchecked.nonNull + + $"%A{items}" + finally + DisplayDepth.Exit () + + override this.ToString () : string = this.StructuredDisplayText + + static member op_Equality (left : EquatableArray<'T>, right : EquatableArray<'T>) : bool = left.Equals right + static member op_Inequality (left : EquatableArray<'T>, right : EquatableArray<'T>) : bool = not (left.Equals right) + + static member op_Implicit (source : EquatableArray<'T>) : ImmutableArray<'T> = source.Items + static member op_Implicit (source : ImmutableArray<'T>) : EquatableArray<'T> = EquatableArray<'T> source + + interface IEquatable> with + /// + member this.Equals other = this.Equals other + + interface IComparable> with + /// + member this.CompareTo other = this.CompareTo other + + interface IComparable with + /// + member this.CompareTo other = + match other with + | :? EquatableArray<'T> as other -> this.CompareTo other + | null -> 1 + | _ -> invalidArg (nameof other) "The object is not an EquatableArray of the same element type." + + interface IEnumerable with + /// + member this.GetEnumerator () : IEnumerator = (this.Items :> IEnumerable).GetEnumerator() + + interface IEnumerable<'T> with + /// + member this.GetEnumerator () : IEnumerator<'T> = (this.Items :> IEnumerable<'T>).GetEnumerator() + + interface IReadOnlyCollection<'T> with + /// + member this.Count = this.Length + + interface IReadOnlyList<'T> with + /// + member this.Item + with get index = this.Items[index] + + // Read-only, as on ImmutableArray: FSharp.Core and LINQ take their fast paths (length, copy) for ICollection<'T> + // and IList<'T>, not for IReadOnlyList<'T>. The members that write throw. + interface ICollection<'T> with + /// + member this.Count = this.Length + + /// + member _.IsReadOnly = true + + /// + member this.Contains item = this.Items.Contains item + + /// + member this.CopyTo (array, arrayIndex) = this.Items.CopyTo (array, arrayIndex) + + /// + member _.Add _ = raise (NotSupportedException ()) + + /// + member _.Clear () = raise (NotSupportedException ()) + + /// + member _.Remove _ = raise (NotSupportedException ()) + + interface IList<'T> with + /// + member this.Item + with get index = this.Items[index] + and set _ _ = raise (NotSupportedException ()) + + /// + member this.IndexOf item = this.Items.IndexOf item + + /// + member _.Insert (_, _) = raise (NotSupportedException ()) + + /// + member _.RemoveAt _ = raise (NotSupportedException ()) + +and [] EquatableArray = + static member Create<'T> (items : ReadOnlySpan<'T>) : EquatableArray<'T> = EquatableArray<'T>(ImmutableArray.Create<'T> items) + +// Absent from the signature: the factory creates it through reflection for each element type +and [] EquatableArrayJsonConverter<'T> () = + inherit JsonConverter> () + + // A JSON null gives the empty array, the counterpart of the default value + override _.HandleNull = true + + override _.Read (reader : byref, _ : Type, options : JsonSerializerOptions) : EquatableArray<'T> = + if reader.TokenType = JsonTokenType.Null then + EquatableArray<'T> ImmutableArray<'T>.Empty + else + // The serializer's own handling of ImmutableArray reads the elements, so element converters and naming + // policies apply. The non-generic overload, because the generic one on a struct result gives FS3265. + match JsonSerializer.Deserialize (&reader, typeof>, options) with + | :? ImmutableArray<'T> as items -> EquatableArray<'T> items + | _ -> EquatableArray<'T> ImmutableArray<'T>.Empty + + override _.Write (writer : Utf8JsonWriter, value : EquatableArray<'T>, options : JsonSerializerOptions) : unit = + JsonSerializer.Serialize (writer, value.Items, options) + +and [] EquatableArrayJsonConverterFactory () = + inherit JsonConverterFactory () + + override _.CanConvert (typeToConvert : Type) : bool = + typeToConvert.IsGenericType + && Type.(=) (typeToConvert.GetGenericTypeDefinition (), typedefof>) + + override _.CreateConverter (typeToConvert : Type, _ : JsonSerializerOptions) : JsonConverter = + let converterType = + typedefof>.MakeGenericType(typeToConvert.GetGenericArguments ()) + + nonNull (Activator.CreateInstance converterType) :?> JsonConverter + +[] +[] +module EquatableArray = + + // ---- Creation ---- + + [] + [] + let empty<'T> : EquatableArray<'T> = EquatableArray<'T> ImmutableArray<'T>.Empty + + [] + let ofArray (array : 'T array) : EquatableArray<'T> = + checkNotNull (nameof array) array + // The copy is referenced by nothing else, so it is wrapped without a second copy + EquatableArray<'T>(ImmutableCollectionsMarshal.AsImmutableArray (Array.copy array)) + + [] + let ofImmutableArray (items : ImmutableArray<'T>) : EquatableArray<'T> = EquatableArray<'T> items + + [] + let ofList (list : 'T list) : EquatableArray<'T> = + EquatableArray<'T>(ImmutableCollectionsMarshal.AsImmutableArray (List.toArray list)) + + [] + let ofSeq (source : seq<'T>) : EquatableArray<'T> = + checkNotNull (nameof source) source + EquatableArray<'T>(ImmutableArray.CreateRange<'T> source) + + [] + let singleton (value : 'T) : EquatableArray<'T> = EquatableArray<'T>(ImmutableArray.Create<'T> value) + + [] + let unsafeOfArray (array : 'T array) : EquatableArray<'T> = + // Without the check, null would become a default array and thus silently the empty array + checkNotNull (nameof array) array + EquatableArray<'T>(ImmutableCollectionsMarshal.AsImmutableArray array) + + // ---- Properties ---- + + [] + let isEmpty (array : EquatableArray<'T>) : bool = array.IsEmpty + + [] + let length (array : EquatableArray<'T>) : int = array.Length + + // ---- Transformation ---- + + [] + let map (mapping : 'T -> 'U) (array : EquatableArray<'T>) : EquatableArray<'U> = + // Items is never a default array, so its underlying array is never null + let source = + array.Items + |> ImmutableCollectionsMarshal.AsArray + |> Unchecked.nonNull + // Array.map returns a new array that nothing else references, so it is wrapped without a copy + EquatableArray<'U>( + source + |> Array.map mapping + |> ImmutableCollectionsMarshal.AsImmutableArray + ) + + // ---- Conversion ---- + + [] + let toImmutableArray (array : EquatableArray<'T>) : ImmutableArray<'T> = array.Items diff --git a/src/FSharp.Collections.Immutable/EquatableArray/EquatableArray.fsi b/src/FSharp.Collections.Immutable/EquatableArray/EquatableArray.fsi new file mode 100644 index 00000000..0d166812 --- /dev/null +++ b/src/FSharp.Collections.Immutable/EquatableArray/EquatableArray.fsi @@ -0,0 +1,308 @@ +namespace FSharp.Collections.Immutable + +open System +open System.Collections.Generic +open System.Collections.Immutable +open System.Runtime.CompilerServices +open System.Text.Json.Serialization + +/// +/// An immutable array with value semantics: two arrays are equal when they hold equal elements in the same order. +/// +/// The type is a struct with one field, an . Thus it is +/// as large as one reference, and a conversion to or from +/// does not copy the elements. +/// +/// +/// Equality and the hash code use all elements and compare them with +/// . Every caller gets this equality: F# equality, +/// , , +/// dictionary and set keys, and the C# equality operators. Elements that are F# arrays compare by reference; use +/// as the element type to nest arrays. +/// +/// +/// Comparison puts a shorter array first. Arrays of the same length compare by their first different element, in the +/// order of F# generic comparison. Strings compare ordinally, never by culture. +/// +/// +/// The default value is the empty array. The type supports equality and comparison only when its element type does. +/// +/// +[] +[, "Create")>] +[)>] +[] +type EquatableArray<[] 'T> = + // A struct must show its fields in a signature (FS0314); the field stays private + val private array : ImmutableArray<'T> + + /// Wraps without a copy. + /// The elements. A default array gives the empty array. + new : items : ImmutableArray<'T> -> EquatableArray<'T> + + /// + /// Gets the elements as an , without a copy. + /// + /// + /// The value is never a default array. For the default value of this type, it is + /// . + /// + member Items : ImmutableArray<'T> + + /// Gets the number of elements. + member Length : int + + /// Gets a value that shows if the array has no elements. + member IsEmpty : bool + + /// Gets the element at . + /// The zero-based position of the element. + /// + /// is less than zero, or it is not less than the length. + /// + member Item : index : int -> 'T with get + + /// + /// Returns an enumerator that is a struct, so that a loop does not allocate. + /// + member GetEnumerator : unit -> ImmutableArray<'T>.Enumerator + + /// Returns a read-only span over the elements, without a copy. + member AsSpan : unit -> ReadOnlySpan<'T> + + /// Returns a read-only memory region over the elements, without a copy. + member AsMemory : unit -> ReadOnlyMemory<'T> + + /// + /// Returns the elements from to . F# slice expressions call this + /// method and get the rules of F# array slices. + /// + /// + /// The index of the first element. If there is no value, or the value is less than zero, the slice starts at the + /// first element. + /// + /// + /// The index of the last element. If there is no value, or the value is not less than the length, the slice ends at + /// the last element. + /// + /// + /// The selected elements. An empty range gives the empty array. A range of all elements gives the same array without + /// a copy; other ranges copy their elements. + /// + /// + /// + /// let numbers = EquatableArray.ofList [ 1; 2; 3; 4 ] + /// numbers[1..2] // [|2; 3|] + /// numbers[2..] // [|3; 4|] + /// numbers[5..9] // [||] + /// + /// + member GetSlice : start : int option * finish : int option -> EquatableArray<'T> + + /// + /// Returns elements from . C# range expressions such as + /// array[1..^1] call this method. + /// + /// The index of the first element. + /// The number of elements. + /// + /// The selected elements. A range of all elements gives the same array without a copy; other ranges copy their + /// elements. + /// + /// + /// The range is not inside the array, as for . + /// + member Slice : start : int * length : int -> EquatableArray<'T> + + /// + /// Returns when has equal elements in the same order. The elements + /// are compared with . + /// + /// The array to compare with. + member Equals : other : EquatableArray<'T> -> bool + + /// + /// Compares the array with : a shorter array comes first, and arrays of the same length + /// compare by their first different element in the order of F# generic comparison. + /// + /// The array to compare with. + /// A negative number, zero or a positive number. + member CompareTo : other : EquatableArray<'T> -> int + + /// Returns when the arrays have equal elements in the same order. + /// C# uses this operator for ==; F# equality calls the same equality. + /// The first array. + /// The second array. + static member op_Equality : left : EquatableArray<'T> * right : EquatableArray<'T> -> bool + + /// Returns when the arrays do not have equal elements in the same order. + /// C# uses this operator for !=. + /// The first array. + /// The second array. + static member op_Inequality : left : EquatableArray<'T> * right : EquatableArray<'T> -> bool + + /// + /// Gives the elements of as an + /// , without a copy and never as a default array. + /// + /// The array. + static member op_Implicit : source : EquatableArray<'T> -> ImmutableArray<'T> + + /// Wraps without a copy. A default array gives the empty array. + /// The elements. + static member op_Implicit : source : ImmutableArray<'T> -> EquatableArray<'T> + + interface IEquatable> + interface IComparable> + interface IComparable + interface IReadOnlyList<'T> + interface IList<'T> + +/// +/// Creates values for C# collection expressions. +/// +/// +/// The class has exactly one method. Collection expressions require one such method on a non-generic builder type, so do +/// not add a second . +/// +and [] EquatableArray = + + /// Copies into a new array. + /// The elements. + /// An array with the elements of , in the same order. + static member Create : items : ReadOnlySpan<'T> -> EquatableArray<'T> + +/// +/// Converts values to and from JSON arrays with +/// System.Text.Json. +/// +/// +/// +/// names this factory in a +/// , so no registration is necessary. +/// +/// +/// The JSON is the same as for , and the converters and the +/// naming policy of the serializer options apply to the elements. The default value is written as an empty JSON array. +/// A JSON null gives the empty array. +/// +/// +and [] EquatableArrayJsonConverterFactory = + inherit JsonConverterFactory + + /// Creates the factory. + new : unit -> EquatableArrayJsonConverterFactory + +/// +/// Functions that create, read and transform values. +/// +[] +[] +module EquatableArray = + + // ---- Creation ---- + + /// Gets the array without elements. + /// The default value of is equal to it. + [] + [] + val empty<'T> : EquatableArray<'T> + + /// Copies the elements of into a new array. + /// The elements. + /// An array with the elements of , in the same order. + /// is . + /// O(n). Later changes to do not change the result. + [] + val ofArray : array : 'T array -> EquatableArray<'T> + + /// Wraps without a copy. + /// The elements. A default array gives the empty array. + /// An array with the elements of . + /// O(1). + [] + val ofImmutableArray : items : ImmutableArray<'T> -> EquatableArray<'T> + + /// Copies the elements of into a new array. + /// The elements. + /// An array with the elements of , in the same order. + /// O(n). + [] + val ofList : list : 'T list -> EquatableArray<'T> + + /// Copies the elements of into a new array. + /// The elements. + /// An array with the elements of , in the same order. + /// is . + /// + /// is a default . + /// + /// + /// O(n). An given as a sequence is not copied, because it + /// cannot change. + /// + [] + val ofSeq : source : seq<'T> -> EquatableArray<'T> + + /// Returns an array with one element. + /// The element. + /// An array that holds only. + [] + val singleton : value : 'T -> EquatableArray<'T> + + /// + /// Wraps without a copy, as + /// does. + /// + /// The elements, in an array that nothing writes to again. + /// An array with the elements of . + /// is . + /// + /// O(1). + /// + /// The caller gives the array away. The result stays immutable only while nothing writes to + /// . A later write changes the elements, the equality and the hash code of the result, also + /// where the result is already a key of a dictionary or an element of a set. Use + /// to copy. + /// + /// + [] + val unsafeOfArray : array : 'T array -> EquatableArray<'T> + + // ---- Properties ---- + + /// Returns when has no elements. + /// The array. + /// O(1). + [] + val isEmpty : array : EquatableArray<'T> -> bool + + /// Returns the number of elements of . + /// The array. + /// O(1). + [] + val length : array : EquatableArray<'T> -> int + + // ---- Transformation ---- + + /// + /// Returns a new array with the results of for each element of + /// , in the same order. + /// + /// The function to apply to each element. + /// The array. + /// O(n). + [] + val map : mapping : ('T -> 'U) -> array : EquatableArray<'T> -> EquatableArray<'U> + + // ---- Conversion ---- + + /// + /// Returns the elements of as an + /// , without a copy. + /// + /// The array. + /// The elements. The value is never a default array. + /// O(1). + [] + val toImmutableArray : array : EquatableArray<'T> -> ImmutableArray<'T> diff --git a/src/FSharp.Collections.Immutable/EquatableArray/EquatableArrayPatterns.fs b/src/FSharp.Collections.Immutable/EquatableArray/EquatableArrayPatterns.fs new file mode 100644 index 00000000..ed966954 --- /dev/null +++ b/src/FSharp.Collections.Immutable/EquatableArray/EquatableArrayPatterns.fs @@ -0,0 +1,27 @@ +namespace FSharp.Collections.Immutable + +[] +module EquatableArrayPatterns = + + // ---- Access ---- + + let (|Arr0|_|) (array : EquatableArray<'T>) : bool = array.IsEmpty + + [] + let (|Arr1|_|) (array : EquatableArray<'T>) : 'T voption = if array.Length = 1 then ValueSome array[0] else ValueNone + + [] + let (|Arr2|_|) (array : EquatableArray<'T>) : struct ('T * 'T) voption = + if array.Length = 2 then + ValueSome (struct (array[0], array[1])) + else + ValueNone + + [] + let (|Arr3|_|) (array : EquatableArray<'T>) : struct ('T * 'T * 'T) voption = + if array.Length = 3 then + ValueSome (struct (array[0], array[1], array[2])) + else + ValueNone + + let (|ArrN|_|) (length : int) (array : EquatableArray<'T>) : bool = array.Length = length diff --git a/src/FSharp.Collections.Immutable/EquatableArray/EquatableArrayPatterns.fsi b/src/FSharp.Collections.Immutable/EquatableArray/EquatableArrayPatterns.fsi new file mode 100644 index 00000000..e5e2f023 --- /dev/null +++ b/src/FSharp.Collections.Immutable/EquatableArray/EquatableArrayPatterns.fsi @@ -0,0 +1,56 @@ +namespace FSharp.Collections.Immutable + +/// +/// Struct partial active patterns that match an by its +/// length. Code over the children of a syntax node can thus read like a list pattern without a conversion to an F# list. +/// +/// A match does not allocate. Each length has its own partial pattern, because a multi-case active pattern returns a +/// on the heap for each match. A length without a pattern of its +/// own falls through to the next case. +/// +/// +/// +/// +/// match arguments with +/// | Arr0 -> "no argument" +/// | Arr1 single -> "one argument" +/// | Arr2 (first, second) -> "two arguments" +/// | Arr3 (first, second, third) -> "three arguments" +/// | ArrN 4 -> "four arguments" +/// | _ -> "more arguments" +/// +/// +[] +module EquatableArrayPatterns = + + // ---- Access ---- + + /// Matches an array without elements. The default value matches. + /// The array to match. + val (|Arr0|_|) : array : EquatableArray<'T> -> bool + + /// Matches an array with exactly one element and gives that element. + /// The array to match. + [] + val (|Arr1|_|) : array : EquatableArray<'T> -> 'T voption + + /// + /// Matches an array with exactly two elements and gives them as a struct tuple. The pattern binds the tuple with or + /// without the keyword. + /// + /// The array to match. + [] + val (|Arr2|_|) : array : EquatableArray<'T> -> struct ('T * 'T) voption + + /// Matches an array with exactly three elements and gives them as a struct tuple. + /// The array to match. + [] + val (|Arr3|_|) : array : EquatableArray<'T> -> struct ('T * 'T * 'T) voption + + /// + /// Matches an array with exactly elements, for the lengths that have no pattern of their + /// own. Use the indexer to read the elements. + /// + /// The number of elements to match, written as the argument of the pattern. + /// The array to match. + val (|ArrN|_|) : length : int -> array : EquatableArray<'T> -> bool diff --git a/src/FSharp.Collections.Immutable/FSharp.Collections.Immutable.fsproj b/src/FSharp.Collections.Immutable/FSharp.Collections.Immutable.fsproj index 293de773..e327e728 100644 --- a/src/FSharp.Collections.Immutable/FSharp.Collections.Immutable.fsproj +++ b/src/FSharp.Collections.Immutable/FSharp.Collections.Immutable.fsproj @@ -44,6 +44,10 @@ + + + + diff --git a/tests/FSharp.Collections.Immutable.CSharp.Tests/CollectionExpressionTests.cs b/tests/FSharp.Collections.Immutable.CSharp.Tests/CollectionExpressionTests.cs new file mode 100644 index 00000000..5efd3583 --- /dev/null +++ b/tests/FSharp.Collections.Immutable.CSharp.Tests/CollectionExpressionTests.cs @@ -0,0 +1,41 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace FSharp.Collections.Immutable.CSharp.Tests; + +/// C# collection expressions, which call the factory that CollectionBuilderAttribute names. +[TestClass, EquatableArrayTestCategory, InteropTestCategory] +public sealed class CollectionExpressionTests +{ + [TestMethod] + public void CollectionExpressionCreatesTheArray() + { + EquatableArray array = [1, 2, 3]; + Assert.AreEqual(EquatableArrayModule.OfArray(new[] { 1, 2, 3 }), array, "a collection expression must give the elements in order"); + } + + [TestMethod] + public void EmptyCollectionExpressionEqualsTheDefaultValue() + { + EquatableArray empty = []; + Assert.IsTrue(empty.IsEmpty, "[] must give the empty array"); + Assert.AreEqual(default, empty, "[] must equal the default value"); + } + + [TestMethod] + public void SpreadsKeepTheOrderOfTheElements() + { + EquatableArray first = [1, 2]; + int[] second = [4]; + EquatableArray spread = [.. first, 3, .. second]; + Assert.AreEqual(EquatableArrayModule.OfArray(new[] { 1, 2, 3, 4 }), spread, "spreads must keep the order of the elements"); + } + + [TestMethod] + public void NestedCollectionExpressionsCompareByElementsAtBothLevels() + { + EquatableArray> nested = [[1], [2, 3]]; + EquatableArray> same = [[1], [2, 3]]; + Assert.AreEqual(2, nested.Length, "the outer array must hold two arrays"); + Assert.AreEqual(same, nested, "separately built nested arrays must be equal"); + } +} diff --git a/tests/FSharp.Collections.Immutable.CSharp.Tests/ComparisonTests.cs b/tests/FSharp.Collections.Immutable.CSharp.Tests/ComparisonTests.cs new file mode 100644 index 00000000..3460f621 --- /dev/null +++ b/tests/FSharp.Collections.Immutable.CSharp.Tests/ComparisonTests.cs @@ -0,0 +1,39 @@ +using System.Globalization; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace FSharp.Collections.Immutable.CSharp.Tests; + +/// Comparison as C# code sees it: sorted collections and the default comparer. +[TestClass, EquatableArrayTestCategory, ComparisonTestCategory] +public sealed class ComparisonTests +{ + [TestMethod] + public void SortedSetOrdersByLengthThenByElements() + { + EquatableArray[] unsorted = [[3], [1, 2], [1]]; + var sorted = new SortedSet>(unsorted); + EquatableArray[] expected = [[1], [3], [1, 2]]; + CollectionAssert.AreEqual(expected, sorted.ToArray(), "a SortedSet must order by length first, then by elements"); + } + + [TestMethod] + public void StringsCompareOrdinallyInTheTurkishCulture() + { + var previous = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = new CultureInfo("tr-TR"); + EquatableArray lower = ["a"]; + EquatableArray upper = ["B"]; + EquatableArray dotted = ["i"]; + EquatableArray dotless = ["ı"]; + var comparer = Comparer>.Default; + Assert.IsGreaterThan(0, comparer.Compare(lower, upper), "'a' must come after 'B' ordinally, not by culture"); + Assert.IsLessThan(0, comparer.Compare(dotted, dotless), "'i' must come before 'ı' ordinally, not by culture"); + } + finally + { + CultureInfo.CurrentCulture = previous; + } + } +} diff --git a/tests/FSharp.Collections.Immutable.CSharp.Tests/ConversionTests.cs b/tests/FSharp.Collections.Immutable.CSharp.Tests/ConversionTests.cs new file mode 100644 index 00000000..97e13523 --- /dev/null +++ b/tests/FSharp.Collections.Immutable.CSharp.Tests/ConversionTests.cs @@ -0,0 +1,55 @@ +using System.Collections.Immutable; +using System.Globalization; +using System.Runtime.InteropServices; +using Microsoft.FSharp.Core; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace FSharp.Collections.Immutable.CSharp.Tests; + +/// Conversions, enumeration and the module functions, as C# code uses them. +[TestClass, EquatableArrayTestCategory, InteropTestCategory] +public sealed class ConversionTests +{ + [TestMethod] + public void ImplicitConversionsKeepTheArray() + { + var items = ImmutableArray.Create(1, 2, 3); + EquatableArray array = items; + ImmutableArray back = array; + Assert.AreSame(ImmutableCollectionsMarshal.AsArray(items), ImmutableCollectionsMarshal.AsArray(array.Items), "the conversion to EquatableArray must not copy"); + Assert.AreSame(ImmutableCollectionsMarshal.AsArray(items), ImmutableCollectionsMarshal.AsArray(back), "the conversion to ImmutableArray must not copy"); + } + + [TestMethod] + public void ImplicitConversionOfTheDefaultValueGivesAnEmptyImmutableArray() + { + ImmutableArray converted = default(EquatableArray); + Assert.IsFalse(converted.IsDefault, "the default value must become an empty, not a default, ImmutableArray"); + } + + [TestMethod] + public void ForeachAndLinqReadTheElements() + { + EquatableArray array = [1, 2, 3]; + var sum = 0; + foreach (var item in array) + { + sum += item; + } + + Assert.AreEqual(6, sum, "foreach must read every element"); + Assert.AreEqual(12, array.Select(item => item * 2).Sum(), "LINQ must read every element"); + Assert.AreEqual(2, array[1], "the indexer must read the element"); + } + + [TestMethod] + public void ModuleFunctionsHaveCSharpNames() + { + var array = EquatableArrayModule.OfSeq(new List { 1, 2 }); + Assert.AreEqual(2, EquatableArrayModule.Length(array), "Length must count the elements"); + var mapped = EquatableArrayModule.Map(FuncConvert.FromFunc(item => item.ToString(CultureInfo.InvariantCulture)), array); + EquatableArray expected = ["1", "2"]; + Assert.AreEqual(expected, mapped, "Map must apply the function to each element"); + Assert.IsTrue(EquatableArrayModule.Empty().IsEmpty, "Empty must give the empty array"); + } +} diff --git a/tests/FSharp.Collections.Immutable.CSharp.Tests/EqualityTests.cs b/tests/FSharp.Collections.Immutable.CSharp.Tests/EqualityTests.cs new file mode 100644 index 00000000..aaf3037f --- /dev/null +++ b/tests/FSharp.Collections.Immutable.CSharp.Tests/EqualityTests.cs @@ -0,0 +1,54 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace FSharp.Collections.Immutable.CSharp.Tests; + +/// Equality as C# code sees it: the operators, the default collections and record structs. +[TestClass, EquatableArrayTestCategory, EqualityTestCategory] +public sealed class EqualityTests +{ + [TestMethod] + public void OperatorsCompareTheElements() + { + EquatableArray left = [1, 2, 3]; + var right = EquatableArrayModule.OfArray(new[] { 1, 2, 3 }); + EquatableArray different = [3, 2, 1]; + + // Each operator result goes into a variable, so that the test reads the operator, not an assertion of it + var equal = left == right; + var notEqual = left != right; + var equalToDifferent = left == different; + Assert.IsTrue(equal, "== must be true for equal elements in the same order"); + Assert.IsFalse(notEqual, "!= must be false for equal elements in the same order"); + Assert.IsFalse(equalToDifferent, "== must be false for elements in another order"); + Assert.AreEqual(left.GetHashCode(), right.GetHashCode(), "equal arrays must have equal hash codes"); + } + + [TestMethod] + public void DefaultValueEqualsTheEmptyArray() + { + EquatableArray empty = []; + var equal = default(EquatableArray) == empty; + Assert.IsTrue(equal, "the default value must equal []"); + } + + [TestMethod] + public void DefaultCollectionsFindEqualArrays() + { + EquatableArray key = [1, 2]; + var equal = EquatableArrayModule.OfArray(new[] { 1, 2 }); + var set = new HashSet> { key }; + Assert.Contains(equal, set, "a HashSet must find an equal array"); + var dictionary = new Dictionary, string> { [key] = "found" }; + Assert.AreEqual("found", dictionary[equal], "a Dictionary must find an equal key"); + } + + [TestMethod] + public void RecordStructsCompareTheElements() => + Assert.AreEqual( + new Holder("n", [1, 2]), + new Holder("n", EquatableArrayModule.OfArray(new[] { 1, 2 })), + "a record struct must compare its array member by elements"); + + /// A record struct that holds an array, as C# models do. + public readonly record struct Holder(string Name, EquatableArray Items); +} diff --git a/tests/FSharp.Collections.Immutable.CSharp.Tests/FSharp.Collections.Immutable.CSharp.Tests.csproj b/tests/FSharp.Collections.Immutable.CSharp.Tests/FSharp.Collections.Immutable.CSharp.Tests.csproj new file mode 100644 index 00000000..627c8f78 --- /dev/null +++ b/tests/FSharp.Collections.Immutable.CSharp.Tests/FSharp.Collections.Immutable.CSharp.Tests.csproj @@ -0,0 +1,24 @@ + + + + net8.0 + $(AssemblyBaseName).CSharp.Tests + + 12.0 + enable + + Exe + true + false + true + + + + + + + + + + + diff --git a/tests/FSharp.Collections.Immutable.CSharp.Tests/IndexAndRangeTests.cs b/tests/FSharp.Collections.Immutable.CSharp.Tests/IndexAndRangeTests.cs new file mode 100644 index 00000000..d6b5fbb6 --- /dev/null +++ b/tests/FSharp.Collections.Immutable.CSharp.Tests/IndexAndRangeTests.cs @@ -0,0 +1,34 @@ +using System.Runtime.InteropServices; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace FSharp.Collections.Immutable.CSharp.Tests; + +/// C# indices and ranges, which use Length, the indexer and Slice. +[TestClass, EquatableArrayTestCategory, InteropTestCategory] +public sealed class IndexAndRangeTests +{ + private static readonly EquatableArray Numbers = [1, 2, 3, 4]; + + [TestMethod] + public void IndexFromTheEndReadsTheElement() => Assert.AreEqual(4, Numbers[^1], "^1 must read the last element"); + + [TestMethod] + public void RangeGivesTheSelectedElements() + { + EquatableArray inner = [2, 3]; + Assert.AreEqual(inner, Numbers[1..^1], "1..^1 must give the inner elements"); + Assert.IsTrue(Numbers[2..2].IsEmpty, "an empty range must give the empty array"); + } + + [TestMethod] + public void RangeOfAllElementsSharesTheArray() + { + var underlying = ImmutableCollectionsMarshal.AsArray(Numbers.Items); + Assert.AreSame(underlying, ImmutableCollectionsMarshal.AsArray(Numbers[..].Items), "a range of all elements must not copy"); + Assert.AreNotSame(underlying, ImmutableCollectionsMarshal.AsArray(Numbers[1..].Items), "a smaller range must copy"); + } + + [TestMethod] + public void ReversedRangeThrows() => + Assert.ThrowsExactly(() => _ = Numbers[2..1], "a range that ends before its start must throw"); +} diff --git a/tests/FSharp.Collections.Immutable.CSharp.Tests/JsonTests.cs b/tests/FSharp.Collections.Immutable.CSharp.Tests/JsonTests.cs new file mode 100644 index 00000000..fa0da205 --- /dev/null +++ b/tests/FSharp.Collections.Immutable.CSharp.Tests/JsonTests.cs @@ -0,0 +1,21 @@ +using System.Text.Json; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace FSharp.Collections.Immutable.CSharp.Tests; + +/// A JSON round trip, as C# code does it. +[TestClass, EquatableArrayTestCategory, JsonTestCategory] +public sealed class JsonTests +{ + [TestMethod] + public void RecordStructRoundTrips() + { + var holder = new Holder("n", [1, 2]); + var json = JsonSerializer.Serialize(holder); + Assert.AreEqual("""{"Name":"n","Items":[1,2]}""", json, "the array member must be written as a JSON array"); + Assert.AreEqual(holder, JsonSerializer.Deserialize(json), "the record struct must be read back"); + } + + /// A record struct that holds an array, as C# models do. + public readonly record struct Holder(string Name, EquatableArray Items); +} diff --git a/tests/FSharp.Collections.Immutable.CSharp.Tests/MSTestSettings.cs b/tests/FSharp.Collections.Immutable.CSharp.Tests/MSTestSettings.cs new file mode 100644 index 00000000..80d05a95 --- /dev/null +++ b/tests/FSharp.Collections.Immutable.CSharp.Tests/MSTestSettings.cs @@ -0,0 +1,3 @@ +// The F# test project sets this in testconfig.json. The MSTest runner reads only .testconfig.json, which +// MSBuild makes from a testconfig.json in the project folder, so a linked file would be ignored; the attribute is explicit. +[assembly: Microsoft.VisualStudio.TestTools.UnitTesting.Parallelize(Workers = 0, Scope = Microsoft.VisualStudio.TestTools.UnitTesting.ExecutionScope.MethodLevel)] diff --git a/tests/FSharp.Collections.Immutable.CSharp.Tests/TestCategories.cs b/tests/FSharp.Collections.Immutable.CSharp.Tests/TestCategories.cs new file mode 100644 index 00000000..abd510c0 --- /dev/null +++ b/tests/FSharp.Collections.Immutable.CSharp.Tests/TestCategories.cs @@ -0,0 +1,40 @@ +// The TestCategoryBaseAttribute descendants that categorize the C# tests, sorted by type name. They give the same +// categories as the attributes of the same names in the F# test project. +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace FSharp.Collections.Immutable.CSharp.Tests; + +/// Categorizes a test of the order of values. +public sealed class ComparisonTestCategoryAttribute : TestCategoryBaseAttribute +{ + /// + public override IList TestCategories { get; } = ["Comparison"]; +} + +/// Categorizes a test of equality and hash codes. +public sealed class EqualityTestCategoryAttribute : TestCategoryBaseAttribute +{ + /// + public override IList TestCategories { get; } = ["Equality"]; +} + +/// Categorizes a test of . +public sealed class EquatableArrayTestCategoryAttribute : TestCategoryBaseAttribute +{ + /// + public override IList TestCategories { get; } = ["EquatableArray"]; +} + +/// Categorizes a test of the members that .NET interfaces, conversions, spans and C# use. +public sealed class InteropTestCategoryAttribute : TestCategoryBaseAttribute +{ + /// + public override IList TestCategories { get; } = ["Interop"]; +} + +/// Categorizes a test of the conversion to and from JSON. +public sealed class JsonTestCategoryAttribute : TestCategoryBaseAttribute +{ + /// + public override IList TestCategories { get; } = ["JSON"]; +} diff --git a/tests/FSharp.Collections.Immutable.Tests/EquatableArray/EquatableArrayAllocationTests.fs b/tests/FSharp.Collections.Immutable.Tests/EquatableArray/EquatableArrayAllocationTests.fs new file mode 100644 index 00000000..504019c2 --- /dev/null +++ b/tests/FSharp.Collections.Immutable.Tests/EquatableArray/EquatableArrayAllocationTests.fs @@ -0,0 +1,93 @@ +namespace FSharp.Collections.Immutable.Tests + +// Release only: a Debug build does not optimize F# equality and the loops that these tests measure, so their counts +// would not show the costs of ADR 0002 §3. CI runs both configurations. +#if !DEBUG + +open System +open Microsoft.VisualStudio.TestTools.UnitTesting +open FSharp.Collections.Immutable + +[] +type EquatableArrayAllocationTests () = + + /// Counts the bytes that action allocates on this thread in 100 calls. The caller creates the closure before the + /// first count. The test project turns off tiered compilation in Release, so the code is fully optimized from the + /// first call, and the first call outside the count does the static initialization. + static let allocatedBytes (action : unit -> unit) = + action () + let before = GC.GetAllocatedBytesForCurrentThread () + + for _ in 1..100 do + action () + + GC.GetAllocatedBytesForCurrentThread () - before + + static let assertNoAllocation (operation : string) (action : unit -> unit) = + Assert.AreEqual (0L, allocatedBytes action, $"%s{operation} must not allocate") + + /// Two equal arrays of 32 elements, built separately. + static let pair (element : int -> 'T) = + EquatableArray.ofArray (Array.init 32 element), EquatableArray.ofArray (Array.init 32 element) + + [] + member _.``the counter sees an allocation`` () = + // The control of the other tests: a count of 0 must mean no allocation, not a counter that sees nothing. + // GC.KeepAlive makes the array escape, so the JIT cannot put it on the stack. + Assert.IsTrue ( + allocatedBytes (fun () -> GC.KeepAlive (Array.zeroCreate 32)) > 0L, + "the counter must see a heap allocation" + ) + + [] + member _.``F# equality allocates nothing for the elements of ADR 0002 §3`` () = + let leftInts, rightInts = pair id + let leftStrings, rightStrings = pair string + let leftUnions, rightUnions = pair Leaf + let leftTuples, rightTuples = pair (fun index -> struct (index, string index)) + let leftPoints, rightPoints = pair (fun index -> { X = index; Y = -index }) + assertNoAllocation "= on int elements" (fun () -> ignore (leftInts = rightInts)) + assertNoAllocation "= on string elements" (fun () -> ignore (leftStrings = rightStrings)) + assertNoAllocation "= on union elements" (fun () -> ignore (leftUnions = rightUnions)) + assertNoAllocation "= on struct tuple elements" (fun () -> ignore (leftTuples = rightTuples)) + assertNoAllocation "= on struct record elements" (fun () -> ignore (leftPoints = rightPoints)) + + [] + member _.``F# equality of a record with an array field allocates nothing`` () = + let first = { Name = "x"; Tags = EquatableArray.ofArray (Array.init 32 string) } + let second = { Name = "x"; Tags = EquatableArray.ofArray (Array.init 32 string) } + assertNoAllocation "= on a record field" (fun () -> ignore (first = second)) + + [] + member _.``hash and CompareTo allocate nothing`` () = + let leftInts, rightInts = pair id + let leftStrings, rightStrings = pair string + assertNoAllocation "hash on int elements" (fun () -> ignore (hash leftInts)) + assertNoAllocation "CompareTo on int elements" (fun () -> ignore (leftInts.CompareTo rightInts)) + assertNoAllocation "CompareTo on string elements" (fun () -> ignore (leftStrings.CompareTo rightStrings)) + + [] + member _.``a for loop and a pattern match allocate nothing`` () = + let array = EquatableArray.ofArray (Array.init 32 id) + let three = EquatableArray.ofArray [| 1; 2; 3 |] + + assertNoAllocation + "a for loop" + (fun () -> + let mutable sum = 0 + + for item in array do + sum <- sum + item + + ignore sum + ) + + assertNoAllocation + "a pattern match" + (fun () -> + match three with + | Arr3 (first, second, third) -> ignore (first + second + third) + | _ -> () + ) + +#endif diff --git a/tests/FSharp.Collections.Immutable.Tests/EquatableArray/EquatableArrayComparisonTests.fs b/tests/FSharp.Collections.Immutable.Tests/EquatableArray/EquatableArrayComparisonTests.fs new file mode 100644 index 00000000..86613c68 --- /dev/null +++ b/tests/FSharp.Collections.Immutable.Tests/EquatableArray/EquatableArrayComparisonTests.fs @@ -0,0 +1,128 @@ +namespace FSharp.Collections.Immutable.Tests + +open System +open System.Collections.Generic +open System.Collections.Immutable +open System.Globalization +open Microsoft.VisualStudio.TestTools.UnitTesting +open FSharp.Collections.Immutable + +[] +type EquatableArrayComparisonTests () = + + /// Runs action with culture as the current culture of this thread, then restores the previous culture. + static let withCulture (culture : string) (action : unit -> unit) = + let previous = CultureInfo.CurrentCulture + + try + CultureInfo.CurrentCulture <- CultureInfo culture + action () + finally + CultureInfo.CurrentCulture <- previous + + [] + member _.``a shorter array comes first, as for F# arrays`` () = + Assert.IsTrue (compare (ea [ 9 ]) (ea [ 1; 2 ]) < 0, "the length must decide before the elements") + Assert.IsTrue (compare [| 9 |] [| 1; 2 |] < 0, "F# arrays have the same order") + Assert.IsTrue (compare [ 9 ] [ 1; 2 ] > 0, "F# lists compare the elements first, so their order differs") + + [] + member _.``arrays of the same length compare by their first different element`` () = + Assert.IsTrue (compare (ea [ 1; 2; 3 ]) (ea [ 1; 3; 0 ]) < 0, "the first different element must decide") + Assert.AreEqual (0, compare (ea [ 1; 2 ]) (ea [ 1; 2 ]), "equal arrays must compare as 0") + Assert.AreEqual (0, (ea [ 1; 2 ]).CompareTo(ea [ 1; 2 ]), "CompareTo must agree with compare") + Assert.IsTrue ( + Comparer>.Default.Compare(ea [ 1 ], ea [ 2 ]) < 0, + "Comparer.Default must use the same order" + ) + + [] + member _.``arrays of different lengths sort and build a Set`` () = + // ImmutableArray throws here; EquatableArray must not + let arrays = [ ea [ 3; 1 ]; ea [ 2 ]; ea [ 1; 2; 3 ]; ea [ 1 ] ] + Assert.AreEqual list>( + [ ea [ 1 ]; ea [ 2 ]; ea [ 3; 1 ]; ea [ 1; 2; 3 ] ], + List.sort arrays, + "sorting must order by length, then by elements" + ) + Assert.HasCount (4, Set.ofList arrays, "a Set must hold arrays of different lengths") + + [] + member _.``strings compare ordinally in every culture`` () = + // Comparer.Default would put "a" before "B" and order "i" and "ı" by culture (ADR 0002 §3) + for culture in [ "tr-TR"; "en-US"; "" ] do + withCulture + culture + (fun () -> + Assert.IsTrue ( + compare (ea [ "a" ]) (ea [ "B" ]) > 0, + $"'a' must come after 'B' ordinally under '%s{culture}'" + ) + Assert.IsTrue ( + compare (ea [ "i" ]) (ea [ "ı" ]) < 0, + $"'i' must come before 'ı' ordinally under '%s{culture}'" + ) + Assert.IsTrue ( + compare (ea [ "I" ]) (ea [ "i" ]) < 0, + $"'I' must come before 'i' ordinally under '%s{culture}'" + ) + ) + + [] + member _.``struct tuples of strings compare in F# order`` () = + Assert.IsTrue ( + compare (ea [ struct ("a", 1) ]) (ea [ struct ("B", 1) ]) > 0, + "the strings in the tuples must compare ordinally" + ) + Assert.IsTrue ( + compare (ea [ struct ("a", 1) ]) (ea [ struct ("a", 2) ]) < 0, + "the second tuple element must decide a tie" + ) + + [] + member _.``F# arrays as elements compare by contents`` () = + Assert.IsTrue (compare (ea [ [| 1 |] ]) (ea [ [| 2 |] ]) < 0, "inner arrays must compare by their elements") + Assert.IsTrue (compare (ea [ [| 1; 2 |] ]) (ea [ [| 9 |] ]) > 0, "inner arrays must compare by length first") + + [] + member _.``NaN comes before every number, unlike in F# lists`` () = + // Pinned on purpose: the comparison operators follow CompareTo, which orders NaN first (ADR 0002 §2) + Assert.IsTrue (ea [ nan ] < ea [ 1.0 ], "EquatableArray orders NaN before every number") + Assert.IsFalse ([ nan ] < [ 1.0 ], "the F# list operators treat NaN as unordered") + Assert.AreEqual (0, compare (ea [ nan ]) (ea [ nan ]), "NaN must compare equal to NaN") + + [] + member _.``the non-generic CompareTo accepts null and rejects other types`` () = + let comparable = ea [ 1 ] :> IComparable + Assert.AreEqual (1, comparable.CompareTo null, "every array must come after null") + Assert.AreEqual (0, comparable.CompareTo (box (ea [ 1 ])), "a boxed equal array must compare as 0") + + Assert.ThrowsExactly( + (fun () -> comparable.CompareTo (box [| 1 |]) |> ignore), + "an object of another type must be rejected" + ) + |> ignore + + [] + member _.``the element type parameter carries ComparisonConditionalOn`` () = + // A test cannot assert a compile error, so the attribute that gives conditional comparison is read by reflection + let typeParameter = + typedefof>.GetGenericArguments() + |> Array.exactlyOne + Assert.IsTrue ( + typeParameter.IsDefined (typeof, false), + "the type must support comparison only when its element type does" + ) + + [] + member _.``ImmutableArray elements of different lengths throw ArgumentException`` () = + // The element comparison is the F# comparison of the element type; for ImmutableArray it throws on different + // lengths, and EquatableArray passes that on (ADR 0002 §2) + Assert.ThrowsExactly( + (fun () -> + compare (ea [ ImmutableArray.Create 1 ]) (ea [ ImmutableArray.Create (1, 2) ]) + |> ignore + ), + "the comparison of the ImmutableArray elements must throw as compare on them does" + ) + |> ignore diff --git a/tests/FSharp.Collections.Immutable.Tests/EquatableArray/EquatableArrayDefaultValueTests.fs b/tests/FSharp.Collections.Immutable.Tests/EquatableArray/EquatableArrayDefaultValueTests.fs new file mode 100644 index 00000000..92f6ae84 --- /dev/null +++ b/tests/FSharp.Collections.Immutable.Tests/EquatableArray/EquatableArrayDefaultValueTests.fs @@ -0,0 +1,90 @@ +namespace FSharp.Collections.Immutable.Tests + +open System +open System.Collections.Immutable +open System.Text.Json +open Microsoft.VisualStudio.TestTools.UnitTesting +open FSharp.Collections.Immutable + +/// Decision 2 of ADR 0002: the default value is the empty array, and the default state of ImmutableArray is never +/// observable. Unchecked.defaultof, Array.zeroCreate and a missing JSON member produce the default value. +[] +type EquatableArrayDefaultValueTests () = + + static let defaultValue = Unchecked.defaultof> + + [] + member _.``the default value equals the empty array and has its hash code`` () = + Assert.AreEqual (EquatableArray.empty, defaultValue, "the default value must equal the empty array") + Assert.IsTrue ((defaultValue = EquatableArray.empty), "F# equality must hold too") + Assert.AreEqual (hash EquatableArray.empty, hash defaultValue, "equal values must have equal hash codes") + Assert.AreEqual (0, compare defaultValue EquatableArray.empty, "the default value must compare equal to the empty array") + + [] + member _.``the default value has no elements`` () = + Assert.AreEqual (0, defaultValue.Length, "the default value must have length 0") + Assert.IsTrue (defaultValue.IsEmpty, "the default value must be empty") + Assert.IsFalse (defaultValue.Items.IsDefault, "Items must never be a default ImmutableArray") + Assert.IsEmpty (defaultValue, "the default value must enumerate no elements") + + [] + member _.``a for loop over the default value runs zero times`` () = + let mutable count = 0 + + for _ in defaultValue do + count <- count + 1 + + Assert.AreEqual (0, count, "a for loop must not throw and must not run") + + [] + member _.``an element of Array.zeroCreate behaves as the empty array`` () = + let element = (Array.zeroCreate> 1)[0] + Assert.IsTrue (element.IsEmpty, "a zero-initialized element must be empty") + Assert.AreEqual (EquatableArray.empty, element, "a zero-initialized element must equal the empty array") + + [] + member _.``the indexer of the default value throws IndexOutOfRangeException`` () = + Assert.ThrowsExactly( + (fun () -> defaultValue[0] |> ignore), + "the indexer must throw as for any empty array, not NullReferenceException" + ) + |> ignore + + [] + member _.``slices of the default value are empty`` () = + Assert.IsTrue (defaultValue[0..5].IsEmpty, "an F# slice of the default value must be empty") + Assert.IsTrue ((defaultValue.Slice (0, 0)).IsEmpty, "an empty Slice of the default value must be empty") + + Assert.ThrowsExactly( + (fun () -> defaultValue.Slice (0, 1) |> ignore), + "Slice beyond the length must throw as for any empty array" + ) + |> ignore + + [] + member _.``conversions of the default value give an empty ImmutableArray`` () = + let converted : ImmutableArray = EquatableArray.op_Implicit defaultValue + Assert.IsFalse (converted.IsDefault, "the implicit conversion must not give a default ImmutableArray") + Assert.IsFalse ( + (EquatableArray.toImmutableArray defaultValue).IsDefault, + "toImmutableArray must not give a default ImmutableArray" + ) + Assert.AreEqual (0, defaultValue.AsSpan().Length, "the span of the default value must be empty") + + [] + member _.``the default value formats as an empty F# array`` () = + Assert.AreEqual ("[||]", $"%A{defaultValue}", "%A must show the default value as [||]") + Assert.AreEqual ("[||]", defaultValue.ToString (), "ToString must show the default value as [||]") + + [] + member _.``the default value serializes as an empty JSON array`` () = + Assert.AreEqual ("[]", JsonSerializer.Serialize defaultValue, "the default value must be written as []") + + [] + member _.``the default value matches Arr0`` () = + let matched = + match defaultValue with + | Arr0 -> true + | _ -> false + + Assert.IsTrue (matched, "the default value must match the pattern of the empty array") diff --git a/tests/FSharp.Collections.Immutable.Tests/EquatableArray/EquatableArrayEqualityTests.fs b/tests/FSharp.Collections.Immutable.Tests/EquatableArray/EquatableArrayEqualityTests.fs new file mode 100644 index 00000000..a2e665b1 --- /dev/null +++ b/tests/FSharp.Collections.Immutable.Tests/EquatableArray/EquatableArrayEqualityTests.fs @@ -0,0 +1,99 @@ +namespace FSharp.Collections.Immutable.Tests + +open System +open System.Collections.Generic +open System.Collections.Immutable +open Microsoft.VisualStudio.TestTools.UnitTesting +open FSharp.Collections.Immutable + +[] +type EquatableArrayEqualityTests () = + + [] + member _.``arrays with equal elements in the same order are equal under every caller`` () = + // Built separately, so that no caller can pass by comparing the reference of one underlying array + let left = EquatableArray.ofArray [| 1; 2; 3 |] + let right = EquatableArray.ofList [ 1; 2; 3 ] + Assert.IsTrue ((left = right), "F# equality must compare the elements") + Assert.IsTrue (left.Equals right, "the typed Equals must compare the elements") + Assert.IsTrue (left.Equals (box right), "Equals (obj) must compare the elements") + Assert.IsTrue ( + EqualityComparer>.Default.Equals(left, right), + "EqualityComparer.Default must compare the elements" + ) + Assert.IsTrue ( + (HashIdentity.Structural>).Equals(left, right), + "HashIdentity.Structural must compare the elements" + ) + Assert.IsTrue ((struct (left, 1)).Equals(struct (right, 1)), "ValueTuple.Equals must compare the elements") + Assert.AreEqual (left.GetHashCode (), right.GetHashCode (), "equal arrays must have equal hash codes") + Assert.AreEqual (hash left, hash right, "F# hash must agree") + + [] + member _.``arrays that differ in order, length or an element are not equal`` () = + let array = ea [ 1; 2; 3 ] + Assert.AreNotEqual (ea [ 3; 2; 1 ], array, "the order of the elements must count") + Assert.AreNotEqual (ea [ 1; 2 ], array, "the length must count") + Assert.AreNotEqual (ea [ 1; 2; 4 ], array, "each element must count") + Assert.IsFalse (array.Equals (box [| 1; 2; 3 |]), "an F# array with the same elements is not an EquatableArray") + Assert.IsFalse (array.Equals (box (ImmutableArray.Create (1, 2, 3))), "an ImmutableArray is not an EquatableArray") + + [] + member _.``separately built equal arrays are one key of a Dictionary and a HashSet`` () = + let dictionary = Dictionary, string>() + dictionary[ea [ 1; 2 ]] <- "found" + Assert.AreEqual ("found", dictionary[EquatableArray.ofArray [| 1; 2 |]], "a Dictionary must find an equal key") + + // Fully qualified: the HashSet of FSharp.Collections.Immutable abbreviates ImmutableHashSet + let set = + System.Collections.Generic.HashSet> [ ea [ 1; 2 ]; EquatableArray.ofArray [| 1; 2 |] ] + + Assert.ContainsSingle (set, "a HashSet must keep equal arrays as one element") + |> ignore + + [] + member _.``equality compares elements with their own equality`` () = + let first = String ([| 'a'; 'b' |]) + let second = String ([| 'a'; 'b' |]) + Assert.AreNotSame (first, second, "the test needs two string instances") + Assert.AreEqual (ea [ first ], ea [ second ], "equal strings in different instances must be equal elements") + + [] + member _.``a record field and a recursive union compare the elements`` () = + let document () = { Name = "x"; Tags = EquatableArray.ofList [ "a"; "b" ] } + Assert.AreEqual (document (), document (), "generated record equality must compare the array elements") + + let tree () = Branch (ea [ Leaf 1; Branch (ea [ Leaf 2; Leaf 3 ]) ]) + Assert.AreEqual (tree (), tree (), "generated union equality must compare the array elements at every level") + Assert.AreEqual (hash (tree ()), hash (tree ()), "equal trees must have equal hash codes") + Assert.AreNotEqual (Branch (ea [ Leaf 1 ]), tree (), "trees with different children must not be equal") + + [] + member _.``the hash code reads every element once, from first to last`` () = + let asked = List() + let array = EquatableArray.ofList [ for number in 0..19 -> HashProbe (number, asked) ] + array.GetHashCode () |> ignore + CollectionAssert.AreEqual (Array.init 20 id, asked.ToArray (), "the hash must read all elements, not the last 8 only") + + [] + member _.``arrays that differ only in the first of 40 elements have different hash codes`` () = + let first = EquatableArray.ofList [ for number in 0..39 -> number ] + let second = EquatableArray.ofList [ for number in 0..39 -> if number = 0 then -1 else number ] + Assert.AreNotEqual (first.GetHashCode (), second.GetHashCode (), "the hash must use the first element too") + + [] + member _.``NaN elements are equal, unlike in F# lists`` () = + // Pinned on purpose: the elements are compared with EqualityComparer.Default, where NaN equals NaN (ADR 0002 §2) + Assert.IsTrue ((ea [ nan ] = ea [ nan ]), "EquatableArray compares NaN elements with Double.Equals") + Assert.IsFalse (([ nan ] = [ nan ]), "an F# list compares NaN elements with F# equality, where NaN is not equal to NaN") + + [] + member _.``the element type parameter carries EqualityConditionalOn`` () = + // A test cannot assert a compile error, so the attribute that gives conditional equality is read by reflection + let typeParameter = + typedefof>.GetGenericArguments() + |> Array.exactlyOne + Assert.IsTrue ( + typeParameter.IsDefined (typeof, false), + "the type must support equality only when its element type does" + ) diff --git a/tests/FSharp.Collections.Immutable.Tests/EquatableArray/EquatableArrayFormattingTests.fs b/tests/FSharp.Collections.Immutable.Tests/EquatableArray/EquatableArrayFormattingTests.fs new file mode 100644 index 00000000..7ac92343 --- /dev/null +++ b/tests/FSharp.Collections.Immutable.Tests/EquatableArray/EquatableArrayFormattingTests.fs @@ -0,0 +1,68 @@ +namespace FSharp.Collections.Immutable.Tests + +open System +open Microsoft.VisualStudio.TestTools.UnitTesting +open FSharp.Collections.Immutable + +[] +type EquatableArrayFormattingTests () = + + /// Asserts that %A and ToString show the array as %A shows the F# array with the same elements. + static let assertShownAsArray (items : 'T array) (message : string) = + let expected = $"%A{items}" + let array = EquatableArray.ofArray items + Assert.AreEqual (expected, $"%A{array}", $"%%A: %s{message}") + Assert.AreEqual (expected, array.ToString (), $"ToString: %s{message}") + + [] + member _.``%A and ToString show the elements in F# array notation`` () = + assertShownAsArray [| 1; 2; 3 |] "numbers must be shown as [|1; 2; 3|]" + assertShownAsArray Array.empty "the empty array must be shown as [||]" + assertShownAsArray [| "a"; "b" |] "strings must be shown with quotes" + assertShownAsArray [| Leaf 1; Leaf 2 |] "union cases must be shown as %A shows them" + Assert.AreEqual ("[|1; 2|]", (ea [ 1; 2 ]).ToString(), "ToString must give the text that the first consumer gives") + + [] + member _.``a nested array shows its children`` () = + let nested = ea [ ea [ 1; 2 ]; EquatableArray.empty ] + Assert.AreEqual ("[|[|1; 2|]; [||]|]", $"%A{nested}", "a nested array must show its elements, not '...'") + + [] + member _.``a cycle through a mutable record stops with ... instead of overflowing the stack`` () = + let node = { Children = EquatableArray.empty } + node.Children <- EquatableArray.singleton node + // Object.ToString is annotated as nullable; this override never returns null + Assert.Contains ("...", nonNull (node.Children.ToString ()), "ToString must stop the cycle with '...'") + Assert.Contains ("...", $"%A{node}", "%A must stop the cycle with '...'") + + [] + member _.``a tree deeper than the depth limit ends in ... and a shallower tree is shown completely`` () = + let deepen (depth : int) = + let mutable tree = Leaf 0 + + for _ in 1..depth do + tree <- Branch (EquatableArray.singleton tree) + + tree + + let deep = $"%A{deepen 10_000}" + Assert.Contains ("...", deep, "a tree deeper than the limit must end in '...'") + Assert.DoesNotContain ("Leaf 0", deep, "the leaf below the limit must not be shown") + Assert.Contains ("Leaf 0", $"%A{deepen 50}", "a tree inside the limit must be shown down to its leaf") + Assert.AreEqual ("[|[|1|]|]", (ea [ ea [ 1 ] ]).ToString(), "a display after a deep one must start at depth 0 again") + + [] + member _.``a record field shows the elements`` () = + let document = { Name = "x"; Tags = ea [ "a"; "b" ] } + Assert.Contains ("""Tags = [|"a"; "b"|]""", $"%A{document}", "the field must show the elements of the array") + + [] + member _.``an array longer than 100 elements is shown as %A shows long F# arrays`` () = + let items = Array.init 150 id + assertShownAsArray items "the display must stop after 100 elements, as for an F# array" + StringAssert.EndsWith ( + (EquatableArray.ofArray items).ToString(), + "...|]", + StringComparison.Ordinal, + "the display must end with '...'" + ) diff --git a/tests/FSharp.Collections.Immutable.Tests/EquatableArray/EquatableArrayInteropTests.fs b/tests/FSharp.Collections.Immutable.Tests/EquatableArray/EquatableArrayInteropTests.fs new file mode 100644 index 00000000..f651a2dc --- /dev/null +++ b/tests/FSharp.Collections.Immutable.Tests/EquatableArray/EquatableArrayInteropTests.fs @@ -0,0 +1,197 @@ +namespace FSharp.Collections.Immutable.Tests + +open System +open System.Collections.Generic +open System.Collections.Immutable +open System.Linq +open System.Reflection +open System.Runtime.CompilerServices +open System.Runtime.InteropServices +open Microsoft.VisualStudio.TestTools.UnitTesting +open FSharp.Collections.Immutable + +[] +type EquatableArrayInteropTests () = + + // Methods that take one type and get the other: F# applies op_Implicit silently at a method argument + static member private TakeImmutable (items : ImmutableArray) : ImmutableArray = items + static member private TakeEquatable (array : EquatableArray) : EquatableArray = array + + [] + member _.``the constructor and Items keep the underlying array`` () = + let items = ImmutableArray.Create (1, 2, 3) + // EquatableArray here is the constructor of the struct, although a non-generic class of the same name exists + let array = EquatableArray items + Assert.AreSame(underlying items, underlying array.Items, "the constructor and Items must not copy") + + [] + member _.``the implicit conversions keep the underlying array`` () = + let items = ImmutableArray.Create (1, 2, 3) + let array = EquatableArrayInteropTests.TakeEquatable items + Assert.AreSame( + underlying items, + underlying array.Items, + "the conversion to EquatableArray must not copy" + ) + let back = EquatableArrayInteropTests.TakeImmutable array + Assert.AreSame(underlying items, underlying back, "the conversion to ImmutableArray must not copy") + + [] + member _.``the implicit conversions never expose a default ImmutableArray`` () = + let fromDefault = EquatableArrayInteropTests.TakeEquatable Unchecked.defaultof> + Assert.IsTrue (fromDefault.IsEmpty, "a default ImmutableArray must become the empty array") + let toImmutable = EquatableArrayInteropTests.TakeImmutable Unchecked.defaultof> + Assert.IsFalse (toImmutable.IsDefault, "the default value must become an empty, not a default, ImmutableArray") + + [] + member _.``CollectionBuilderAttribute names the factory class, which has exactly one Create`` () = + let attribute = + typedefof>.GetCustomAttributes(typeof, false) + |> Array.exactlyOne + :?> CollectionBuilderAttribute + + Assert.AreEqual( + typeof, + attribute.BuilderType, + "C# collection expressions must use the factory class" + ) + Assert.AreEqual ("Create", attribute.MethodName, "C# collection expressions must call Create") + + let creates = + typeof.GetMethods(BindingFlags.Public ||| BindingFlags.Static) + |> Array.filter (fun method -> method.Name = "Create") + + // F# collection expressions under RFC FS-1342 accept exactly one such method + Assert.HasCount (1, creates, "the factory class must have exactly one Create") + + [] + member _.``Create copies the span`` () = + let source = [| 1; 2 |] + let array = EquatableArray.Create (ReadOnlySpan source) + Assert.AreEqual (ea [ 1; 2 ], array, "Create must keep the elements") + Assert.AreNotSame(source, underlying array.Items, "Create must copy the span") + + [] + member _.``F# slices follow the rules of F# array slices`` () = + let source = [| 1; 2; 3; 4 |] + let array = EquatableArray.ofArray source + + let check (expected : int array) (actual : EquatableArray) (message : string) = + Assert.AreEqual (EquatableArray.ofArray expected, actual, message) + + check source[1..2] array[1..2] "a range inside the array" + check source[..1] array[..1] "a range without a start" + check source[2..] array[2..] "a range without an end" + check source[-1 .. 1] array[-1 .. 1] "a negative start must count as the first element" + check source[2..10] array[2..10] "a finish beyond the end must count as the last element" + check source[5..9] array[5..9] "a range after the end must be empty" + check source[2..1] array[2..1] "a reversed range must be empty" + + [] + member _.``F# slices with extreme reversed bounds are empty`` () = + // The length of such a range overflows when it is computed before the bounds are compared + let array = ea [ 1; 2; 3; 4 ] + Assert.IsTrue (array[2 .. Int32.MinValue].IsEmpty, "a finish of Int32.MinValue must give the empty array") + Assert.IsTrue (array[Int32.MaxValue .. Int32.MinValue].IsEmpty, "the most reversed range must give the empty array") + Assert.IsTrue (array[Int32.MaxValue ..].IsEmpty, "a start of Int32.MaxValue must give the empty array") + + [] + member _.``an F# slice of all elements shares the array and other slices copy`` () = + let array = ea [ 1; 2; 3; 4 ] + Assert.AreSame( + underlying array.Items, + underlying array[*].Items, + "a slice of all elements must not copy" + ) + Assert.AreSame( + underlying array.Items, + underlying array[0..3].Items, + "a range of all elements must not copy" + ) + Assert.AreNotSame(underlying array.Items, underlying array[1..2].Items, "a smaller range must copy") + + [] + member _.``Slice follows ImmutableArray.Slice`` () = + let array = ea [ 1; 2; 3; 4 ] + Assert.AreEqual (ea [ 2; 3 ], array.Slice (1, 2), "Slice must take length elements from start") + Assert.AreSame( + underlying array.Items, + underlying (array.Slice (0, 4)).Items, + "a Slice of all elements must not copy" + ) + Assert.IsTrue ((array.Slice (4, 0)).IsEmpty, "an empty range at the end must be valid") + + Assert.ThrowsExactly( + (fun () -> array.Slice (3, 2) |> ignore), + "a range beyond the end must throw, as C# ranges expect" + ) + |> ignore + + Assert.ThrowsExactly( + (fun () -> array.Slice (-1, 1) |> ignore), + "a negative start must throw" + ) + |> ignore + + [] + member _.``the C# equality operators agree with Equals`` () = + let array = ea [ 1; 2 ] + let equal = EquatableArray.ofArray [| 1; 2 |] + let different = ea [ 2; 1 ] + Assert.IsTrue (EquatableArray.op_Equality(array, equal), "== must be true for equal arrays") + Assert.IsFalse (EquatableArray.op_Inequality(array, equal), "!= must be false for equal arrays") + Assert.IsFalse (EquatableArray.op_Equality(array, different), "== must be false for different arrays") + Assert.IsTrue (EquatableArray.op_Inequality(array, different), "!= must be true for different arrays") + + [] + member _.``IReadOnlyList gives the count, the elements and their order`` () = + let list = ea [ 10; 20; 30 ] :> IReadOnlyList + Assert.HasCount (3, list, "the count must be the length") + Assert.AreEqual (20, list[1], "the indexer must give the element") + CollectionAssert.AreEqual ([| 10; 20; 30 |], Seq.toArray list, "the enumeration must keep the order") + + [] + member _.``IList is read-only`` () = + let list = ea [ 10; 20; 30 ] :> IList + Assert.IsTrue (list.IsReadOnly, "the array must report that it is read-only") + Assert.AreEqual (1, list.IndexOf 20, "IndexOf must find the element") + Assert.IsTrue (list.Contains 30, "Contains must find the element") + let target = Array.zeroCreate 4 + list.CopyTo (target, 1) + CollectionAssert.AreEqual ([| 0; 10; 20; 30 |], target, "CopyTo must copy the elements to the index") + + let writers : (string * (unit -> unit)) list = [ + "Add", (fun () -> list.Add 40) + "Clear", (fun () -> list.Clear ()) + "Remove", (fun () -> list.Remove 10 |> ignore) + "Insert", (fun () -> list.Insert (0, 5)) + "RemoveAt", (fun () -> list.RemoveAt 0) + "the indexer setter", (fun () -> list[0] <- 5) + ] + + for name, write in writers do + Assert.ThrowsExactly( + (fun () -> write ()), + $"%s{name} must throw, because the array is read-only" + ) + |> ignore + + [] + member _.``AsSpan and AsMemory show the elements without a copy`` () = + let array = ea [ 1; 2; 3 ] + CollectionAssert.AreEqual ([| 1; 2; 3 |], array.AsSpan().ToArray(), "the span must show the elements") + let mutable segment = ArraySegment() + Assert.IsTrue (MemoryMarshal.TryGetArray (array.AsMemory (), &segment), "the memory must be backed by an array") + Assert.AreSame(underlying array.Items, segment.Array, "the memory must not copy the elements") + + [] + member _.``FSharp.Core and LINQ read the elements`` () = + let array = ea [ 1; 2; 3 ] + Assert.AreEqual (3, Seq.length array, "Seq.length must count the elements") + CollectionAssert.AreEqual ([| 1; 2; 3 |], Seq.toArray array, "Seq.toArray must copy the elements in order") + Assert.AreEqual (3, Enumerable.Count array, "Enumerable.Count must count the elements") + CollectionAssert.AreEqual ( + [| 2; 4; 6 |], + array.Select(fun item -> item * 2).ToArray(), + "LINQ must enumerate the elements" + ) diff --git a/tests/FSharp.Collections.Immutable.Tests/EquatableArray/EquatableArrayJsonTests.fs b/tests/FSharp.Collections.Immutable.Tests/EquatableArray/EquatableArrayJsonTests.fs new file mode 100644 index 00000000..71085484 --- /dev/null +++ b/tests/FSharp.Collections.Immutable.Tests/EquatableArray/EquatableArrayJsonTests.fs @@ -0,0 +1,181 @@ +namespace FSharp.Collections.Immutable.Tests + +open System +open System.Collections.Generic +open System.Collections.Immutable +open System.Text.Json +open System.Text.Json.Serialization +open Microsoft.VisualStudio.TestTools.UnitTesting +open FSharp.Collections.Immutable + +/// The serializer options of the tests and a reader that suits F# nullness. +module internal JsonText = + + /// Plain System.Text.Json, without any registration. + let plain = JsonSerializerOptions () + + /// FSharp.SystemTextJson with its defaults. + let fsharp = JsonFSharpOptions.Default().ToJsonSerializerOptions() + + /// FSharp.SystemTextJson with skippable option fields. + let skippable = + JsonFSharpOptions + .Default() + .WithSkippableOptionFields(SkippableOptionFields.Always, deserializeNullAsNone = true) + .ToJsonSerializerOptions() + + /// Every option set, with a name for the failure messages. + let all = [ + "plain System.Text.Json", plain + "FSharp.SystemTextJson", fsharp + "FSharp.SystemTextJson with skippable options", skippable + ] + + /// Reads a JSON text through the non-generic overload: the generic one returns a value that can be null, which + /// F# nullness rejects for a struct result (FS3265). + let read<'T> (options : JsonSerializerOptions) (json : string) : 'T = + match JsonSerializer.Deserialize (json, typeof<'T>, options) with + | :? 'T as value -> value + | _ -> failwith $"The JSON %s{json} did not give a value of type %s{typeof<'T>.Name}." + + /// Writes a value as a JSON text. + let write (options : JsonSerializerOptions) (value : 'T) : string = JsonSerializer.Serialize (value, options) + +[] +type EquatableArrayJsonTests () = + + [] + member _.``an array is written as a JSON array and read back`` () = + for name, options in JsonText.all do + let array = ea [ "a"; "b" ] + Assert.AreEqual ( + """["a","b"]""", + JsonText.write options array, + $"%s{name}: the array must be written as a JSON array" + ) + Assert.AreEqual ( + array, + JsonText.read> options """["a","b"]""", + $"%s{name}: a JSON array must be read back" + ) + + [] + member _.``the JSON is the same as for ImmutableArray`` () = + Assert.AreEqual ( + JsonText.write JsonText.plain (ImmutableArray.Create (1, 2)), + JsonText.write JsonText.plain (ea [ 1; 2 ]), + "a document member can change from ImmutableArray to EquatableArray without a change of the stored JSON" + ) + + [] + member _.``a record round-trips`` () = + for name, options in JsonText.all do + let document = { Name = "x"; Tags = ea [ "a"; "b" ] } + let json = JsonText.write options document + Assert.AreEqual ("""{"Name":"x","Tags":["a","b"]}""", json, $"%s{name}: the record must be written with a JSON array") + Assert.AreEqual (document, JsonText.read options json, $"%s{name}: the record must be read back") + + [] + member _.``a default member is written as an empty JSON array`` () = + for name, options in JsonText.all do + let json = JsonText.write options { Name = "x"; Tags = Unchecked.defaultof<_> } + Assert.AreEqual ("""{"Name":"x","Tags":[]}""", json, $"%s{name}: the default value must be written as []") + + [] + member _.``a missing or null member is the empty array under plain System.Text.Json`` () = + let missing = JsonText.read JsonText.plain """{"Name":"x"}""" + Assert.IsTrue (missing.Tags.IsEmpty, "a missing member must give the default value, which is empty") + let explicitNull = JsonText.read JsonText.plain """{"Name":"x","Tags":null}""" + Assert.IsTrue (explicitNull.Tags.IsEmpty, "a null member must give the empty array") + + [] + member _.``a missing or null member is an error of the FSharp.SystemTextJson record converter`` () = + // FSharp.SystemTextJson requires every field that is not an option; the converter of the array is not called + for name, options in [ "defaults", JsonText.fsharp; "skippable options", JsonText.skippable ] do + Assert.ThrowsExactly( + (fun () -> JsonText.read options """{"Name":"x"}""" |> ignore), + $"%s{name}: a missing member must be an error" + ) + |> ignore + + Assert.ThrowsExactly( + (fun () -> + JsonText.read options """{"Name":"x","Tags":null}""" + |> ignore + ), + $"%s{name}: a null member must be an error" + ) + |> ignore + + [] + member _.``a JSON null is the empty array`` () = + for name, options in JsonText.all do + Assert.IsTrue ( + (JsonText.read> options "null").IsEmpty, + $"%s{name}: null must give the empty array" + ) + + [] + member _.``a JSON value that is not an array is an error`` () = + Assert.ThrowsExactly( + (fun () -> + JsonText.read> JsonText.plain """{"a":1}""" + |> ignore + ), + "an object must not be read as an array" + ) + |> ignore + + [] + member _.``a mutable class and a dictionary value round-trip`` () = + for name, options in JsonText.all do + let json = JsonText.write options (ClassDocument (Name = "x", Tags = ea [ "a" ])) + Assert.AreEqual ( + ea [ "a" ], + (JsonText.read options json).Tags, + $"%s{name}: a class property must round-trip" + ) + + let dictionary = Dictionary>() + dictionary["k"] <- ea [ 1; 2 ] + let back = + JsonText.read>> options (JsonText.write options dictionary) + Assert.AreEqual (ea [ 1; 2 ], back["k"], $"%s{name}: a dictionary value must round-trip") + + [] + member _.``nested arrays, value options, unions and struct tuples round-trip with FSharp.SystemTextJson`` () = + let value = { + Rows = ea [ ea [ 1; 2 ]; EquatableArray.empty ] + Maybe = ValueSome (ea [ 3 ]) + Shapes = ea [ Circle 1.0; Polygon (ea [ struct (0.0, 1.0) ]) ] + } + + for name, options in [ "defaults", JsonText.fsharp; "skippable options", JsonText.skippable ] do + let json = JsonText.write options value + Assert.AreEqual (value, JsonText.read options json, $"%s{name}: the value must round-trip") + + [] + member _.``the converters and the naming policy of the options reach the elements`` () = + let options = JsonSerializerOptions (PropertyNamingPolicy = JsonNamingPolicy.CamelCase) + options.Converters.Add (JsonStringEnumConverter ()) + let days = ea [ DayOfWeek.Monday; DayOfWeek.Friday ] + Assert.AreEqual ("""["Monday","Friday"]""", JsonText.write options days, "an element converter must write the elements") + Assert.AreEqual ( + days, + JsonText.read> options """["Monday","Friday"]""", + "an element converter must read the elements" + ) + + let documents = ea [ { Name = "x"; Tags = EquatableArray.empty } ] + Assert.AreEqual ( + """[{"name":"x","tags":[]}]""", + JsonText.write options documents, + "the naming policy must apply to the elements" + ) + + [] + member _.``an explicitly registered factory still works`` () = + let options = JsonSerializerOptions () + options.Converters.Add (EquatableArrayJsonConverterFactory ()) + Assert.AreEqual ("[1]", JsonText.write options (ea [ 1 ]), "a registered factory must write the array") + Assert.AreEqual (ea [ 1 ], JsonText.read> options "[1]", "a registered factory must read the array") diff --git a/tests/FSharp.Collections.Immutable.Tests/EquatableArray/EquatableArrayKeyTests.fs b/tests/FSharp.Collections.Immutable.Tests/EquatableArray/EquatableArrayKeyTests.fs new file mode 100644 index 00000000..ac815401 --- /dev/null +++ b/tests/FSharp.Collections.Immutable.Tests/EquatableArray/EquatableArrayKeyTests.fs @@ -0,0 +1,59 @@ +namespace FSharp.Collections.Immutable.Tests + +open System.Collections.Generic +open System.Collections.Immutable +open Microsoft.VisualStudio.TestTools.UnitTesting +open FSharp.Collections.Immutable + +/// Decision 5 of ADR 0002, pinned as regression tests: for elements that are F# arrays, ImmutableArrays or tuples that +/// hold them, equality compares the inner arrays by reference and comparison by contents, so hash-based and sorted +/// collections disagree on which values are one key. For other element types they agree. +[] +type EquatableArrayKeyTests () = + + // The BCL HashSet and SortedSet are written fully qualified: the HashSet and SortedSet of + // FSharp.Collections.Immutable abbreviate the immutable sets, and that open comes last + + /// Asserts that x and y are two keys for hash-based collections and one key for sorted collections. + static let assertKeySplit (name : string) (x : EquatableArray<'T>) (y : EquatableArray<'T>) = + Assert.IsFalse ((x = y), $"%s{name}: equality must compare the inner arrays by reference") + Assert.AreEqual (0, compare x y, $"%s{name}: comparison must compare the inner arrays by contents") + let dictionary = Dictionary, int>() + dictionary[x] <- 1 + dictionary[y] <- 2 + Assert.HasCount (2, dictionary, $"%s{name}: a Dictionary must hold two keys") + Assert.HasCount (2, System.Collections.Generic.HashSet<_> [ x; y ], $"%s{name}: a HashSet must hold two keys") + Assert.HasCount (2, ImmutableHashSet.CreateRange [ x; y ], $"%s{name}: an ImmutableHashSet must hold two keys") + Assert.HasCount (1, Set.ofList [ x; y ], $"%s{name}: a Set must hold one key") + Assert.HasCount (1, Map.ofList [ x, 1; y, 2 ], $"%s{name}: a Map must hold one key") + Assert.HasCount (1, System.Collections.Generic.SortedSet<_> [ x; y ], $"%s{name}: a SortedSet must hold one key") + Assert.HasCount (1, ImmutableSortedSet.CreateRange [ x; y ], $"%s{name}: an ImmutableSortedSet must hold one key") + + /// Asserts that x and y are one key for every collection. + static let assertOneKey (name : string) (x : EquatableArray<'T>) (y : EquatableArray<'T>) = + Assert.IsTrue ((x = y), $"%s{name}: equality must compare the elements") + Assert.AreEqual (0, compare x y, $"%s{name}: comparison must agree with equality") + Assert.HasCount (1, System.Collections.Generic.HashSet<_> [ x; y ], $"%s{name}: a HashSet must hold one key") + Assert.HasCount (1, Set.ofList [ x; y ], $"%s{name}: a Set must hold one key") + Assert.HasCount (1, System.Collections.Generic.SortedSet<_> [ x; y ], $"%s{name}: a SortedSet must hold one key") + + [] + member _.``F# arrays as elements are two keys for hash-based collections and one key for sorted ones`` () = + // The inner arrays have equal contents but are allocated separately + assertKeySplit "int array" (ea [ [| 1; 2 |] ]) (ea [ [| 1; 2 |] ]) + + [] + member _.``ImmutableArrays as elements are two keys for hash-based collections and one key for sorted ones`` () = + assertKeySplit "ImmutableArray" (ea [ ImmutableArray.Create (1, 2) ]) (ea [ ImmutableArray.Create (1, 2) ]) + + [] + member _.``tuples of F# arrays as elements are two keys for hash-based collections and one key for sorted ones`` () = + assertKeySplit "struct tuple" (ea [ struct ([| 1; 2 |], 3) ]) (ea [ struct ([| 1; 2 |], 3) ]) + assertKeySplit "reference tuple" (ea [ ([| 1; 2 |], 3) ]) (ea [ ([| 1; 2 |], 3) ]) + + [] + member _.``nested EquatableArrays, records and unions are one key for every collection`` () = + assertOneKey "EquatableArray" (ea [ ea [ 1; 2 ] ]) (ea [ ea [ 1; 2 ] ]) + assertOneKey "record" (ea [ { Name = "x"; Tags = ea [ "a" ] } ]) (ea [ { Name = "x"; Tags = ea [ "a" ] } ]) + assertOneKey "union with an array field" (ea [ Branch (ea [ Leaf 1 ]) ]) (ea [ Branch (ea [ Leaf 1 ]) ]) + assertOneKey "NaN" (ea [ nan ]) (ea [ nan ]) diff --git a/tests/FSharp.Collections.Immutable.Tests/EquatableArray/EquatableArrayModuleTests.fs b/tests/FSharp.Collections.Immutable.Tests/EquatableArray/EquatableArrayModuleTests.fs new file mode 100644 index 00000000..d851d0c6 --- /dev/null +++ b/tests/FSharp.Collections.Immutable.Tests/EquatableArray/EquatableArrayModuleTests.fs @@ -0,0 +1,169 @@ +namespace FSharp.Collections.Immutable.Tests + +open System +open System.Collections.Generic +open System.Collections.Immutable +open Microsoft.VisualStudio.TestTools.UnitTesting +open FSharp.Collections.Immutable + +[] +type EquatableArrayModuleTests () = + + [] + member _.``empty has no elements and equals the default value`` () = + let empty = EquatableArray.empty + Assert.IsTrue (empty.IsEmpty, "empty must have no elements") + Assert.IsFalse (empty.Items.IsDefault, "the elements of empty must not be a default ImmutableArray") + Assert.AreEqual (Unchecked.defaultof>, empty, "empty must equal the default value") + + [] + member _.``ofArray copies the elements`` () = + let source = [| 1; 2; 3 |] + let array = EquatableArray.ofArray source + source[0] <- 9 + Assert.AreEqual (ea [ 1; 2; 3 ], array, "a later change of the source must not change the result") + Assert.AreNotSame(source, underlying array.Items, "ofArray must not share the source") + + [] + member _.``ofArray rejects null`` () = + let ex = + Assert.ThrowsExactly( + (fun () -> + EquatableArray.ofArray Unchecked.defaultof + |> ignore + ), + "ofArray must reject null as Array.copy does" + ) + + Assert.AreEqual("array", ex.ParamName, "the exception must name the parameter") + + [] + member _.``ofImmutableArray shares the array and turns a default array into the empty array`` () = + let items = ImmutableArray.Create (1, 2, 3) + Assert.AreSame( + underlying items, + underlying (EquatableArray.ofImmutableArray items).Items, + "ofImmutableArray must not copy" + ) + + let fromDefault = EquatableArray.ofImmutableArray Unchecked.defaultof> + Assert.IsTrue (fromDefault.IsEmpty, "a default ImmutableArray must give the empty array") + Assert.IsFalse (fromDefault.Items.IsDefault, "the elements must never be a default ImmutableArray") + + [] + member _.``ofList keeps the order of the elements`` () = + Assert.AreEqual (EquatableArray.ofArray [| 3; 1; 2 |], EquatableArray.ofList [ 3; 1; 2 ], "ofList must keep the order") + Assert.IsTrue ((EquatableArray.ofList List.empty).IsEmpty, "an empty list must give the empty array") + + [] + member _.``ofSeq copies a list or an array`` () = + let source = [| 1; 2; 3 |] + let array = EquatableArray.ofSeq source + source[0] <- 9 + Assert.AreEqual (ea [ 1; 2; 3 ], array, "a later change of the source array must not change the result") + Assert.AreEqual (ea [ 1; 2 ], EquatableArray.ofSeq (List [ 1; 2 ]), "ofSeq must copy a List") + Assert.AreEqual (ea [ 0; 1; 2 ], EquatableArray.ofSeq (Seq.init 3 id), "ofSeq must read a lazy sequence") + + [] + member _.``ofSeq shares an ImmutableArray and rejects a default one`` () = + let items = ImmutableArray.Create (1, 2) + Assert.AreSame( + underlying items, + underlying (EquatableArray.ofSeq items).Items, + "ofSeq must not copy an ImmutableArray, which cannot change" + ) + + Assert.ThrowsExactly( + (fun () -> + EquatableArray.ofSeq Unchecked.defaultof> + |> ignore + ), + "ofSeq must reject a default ImmutableArray, which cannot be enumerated" + ) + |> ignore + + [] + member _.``ofSeq rejects null`` () = + let ex = + Assert.ThrowsExactly( + (fun () -> EquatableArray.ofSeq Unchecked.defaultof |> ignore), + "ofSeq must reject null as Array.ofSeq does" + ) + + Assert.AreEqual("source", ex.ParamName, "the exception must name the parameter") + + [] + member _.``singleton holds one element`` () = + let array = EquatableArray.singleton "a" + Assert.AreEqual (1, array.Length, "singleton must hold one element") + Assert.AreEqual ("a", array[0], "singleton must hold the given element") + + [] + member _.``unsafeOfArray shares the array`` () = + let source = [| 1; 2; 3 |] + let array = EquatableArray.unsafeOfArray source + Assert.AreSame(source, underlying array.Items, "unsafeOfArray must not copy") + source[0] <- 9 + Assert.AreEqual (9, array[0], "a later write to the source shows in the result, as the documentation warns") + + [] + member _.``unsafeOfArray works as a first-class function`` () = + let arrays = + [ [| 1 |]; [| 2; 3 |] ] + |> List.map EquatableArray.unsafeOfArray + Assert.AreEqual list>([ ea [ 1 ]; ea [ 2; 3 ] ], arrays, "unsafeOfArray must work in List.map") + + [] + member _.``unsafeOfArray rejects null`` () = + let ex = + Assert.ThrowsExactly( + (fun () -> + EquatableArray.unsafeOfArray Unchecked.defaultof + |> ignore + ), + "unsafeOfArray must reject null instead of giving the empty array" + ) + + Assert.AreEqual("array", ex.ParamName, "the exception must name the parameter") + + [] + member _.``isEmpty is true only without elements`` () = + Assert.IsTrue (EquatableArray.isEmpty EquatableArray.empty, "the empty array must be empty") + Assert.IsTrue (EquatableArray.isEmpty Unchecked.defaultof>, "the default value must be empty") + Assert.IsFalse (EquatableArray.isEmpty (ea [ 1 ]), "an array with an element must not be empty") + + [] + member _.``length counts the elements`` () = + Assert.AreEqual (0, EquatableArray.length Unchecked.defaultof>, "the default value has no elements") + Assert.AreEqual (3, EquatableArray.length (ea [ 1; 2; 3 ]), "length must count every element") + + [] + member _.``map applies the function to each element in order`` () = + let calls = List() + + let mapped = + ea [ 1; 2; 3 ] + |> EquatableArray.map (fun item -> + calls.Add item + string (item * 10) + ) + + Assert.AreEqual (ea [ "10"; "20"; "30" ], mapped, "map must apply the function to each element") + CollectionAssert.AreEqual ([| 1; 2; 3 |], calls.ToArray (), "map must call the function once per element, in order") + Assert.IsTrue ( + (EquatableArray.map string Unchecked.defaultof>).IsEmpty, + "map of the default value must be empty" + ) + + [] + member _.``toImmutableArray shares the array and never gives a default array`` () = + let items = ImmutableArray.Create (1, 2) + Assert.AreSame( + underlying items, + underlying (EquatableArray.toImmutableArray (EquatableArray items)), + "toImmutableArray must not copy" + ) + + let fromDefault = EquatableArray.toImmutableArray Unchecked.defaultof> + Assert.IsFalse (fromDefault.IsDefault, "the default value must give an empty, not a default, ImmutableArray") + Assert.IsTrue (fromDefault.IsEmpty, "the default value must give an empty ImmutableArray") diff --git a/tests/FSharp.Collections.Immutable.Tests/EquatableArray/EquatableArrayPatternTests.fs b/tests/FSharp.Collections.Immutable.Tests/EquatableArray/EquatableArrayPatternTests.fs new file mode 100644 index 00000000..6ee4bfa4 --- /dev/null +++ b/tests/FSharp.Collections.Immutable.Tests/EquatableArray/EquatableArrayPatternTests.fs @@ -0,0 +1,60 @@ +namespace FSharp.Collections.Immutable.Tests + +open System.Collections.Immutable +open Microsoft.VisualStudio.TestTools.UnitTesting +open FSharp.Collections.Immutable + +[] +type EquatableArrayPatternTests () = + + /// Names the pattern that an array of up to five elements matches first. + static let describe (array : EquatableArray) = + match array with + | Arr0 -> "none" + | Arr1 single -> $"one {single}" + | Arr2 (first, second) -> $"two {first} {second}" + | Arr3 (first, second, third) -> $"three {first} {second} {third}" + | ArrN 4 -> $"four {array[3]}" + | _ -> "more" + + [] + member _.``each length matches its pattern and longer arrays fall through`` () = + Assert.AreEqual ("none", describe EquatableArray.empty, "the empty array must match Arr0") + Assert.AreEqual ("one 1", describe (ea [ 1 ]), "one element must match Arr1") + Assert.AreEqual ("two 1 2", describe (ea [ 1; 2 ]), "two elements must match Arr2") + Assert.AreEqual ("three 1 2 3", describe (ea [ 1; 2; 3 ]), "three elements must match Arr3") + Assert.AreEqual ("four 4", describe (ea [ 1; 2; 3; 4 ]), "four elements must match ArrN 4") + Assert.AreEqual ("more", describe (ea [ 1; 2; 3; 4; 5 ]), "five elements must fall through to the next case") + + [] + member _.``the default value matches Arr0`` () = + Assert.AreEqual ("none", describe Unchecked.defaultof>, "the default value must match Arr0") + + [] + member _.``a struct tuple payload also binds with the struct keyword`` () = + let sum = + match ea [ 1; 2 ] with + | Arr2 (struct (first, second)) -> first + second + | _ -> 0 + + Assert.AreEqual (3, sum, "Arr2 must bind the struct tuple with the struct keyword too") + + [] + member _.``a pattern nested in a union case matches the children`` () = + let sum = + match Branch (ea [ Leaf 1; Leaf 2 ]) with + | Branch (Arr2 (Leaf first, Leaf second)) -> first + second + | _ -> 0 + + Assert.AreEqual (3, sum, "Arr2 must match the children of a union case and their own patterns") + + [] + member _.``an ImmutableArray matches after a wrap without a copy`` () = + let items = ImmutableArray.Create (7, 8) + + let matched = + match EquatableArray items with + | Arr2 (first, second) -> first * 10 + second + | _ -> 0 + + Assert.AreEqual (78, matched, "a wrapped ImmutableArray must match the patterns") diff --git a/tests/FSharp.Collections.Immutable.Tests/EquatableArray/EquatableArrayTestTypes.fs b/tests/FSharp.Collections.Immutable.Tests/EquatableArray/EquatableArrayTestTypes.fs new file mode 100644 index 00000000..b53342ff --- /dev/null +++ b/tests/FSharp.Collections.Immutable.Tests/EquatableArray/EquatableArrayTestTypes.fs @@ -0,0 +1,55 @@ +namespace FSharp.Collections.Immutable.Tests + +open System.Collections.Generic +open System.Collections.Immutable +open System.Runtime.InteropServices +open FSharp.Collections.Immutable + +/// A persisted document with an array member. +type Document = { Name : string; Tags : EquatableArray } + +/// A mutable class with an array property, as System.Text.Json fills it. +type ClassDocument () = + member val Name = "" with get, set + member val Tags = EquatableArray.empty with get, set + +/// A node of a recursive syntax tree whose children are an array. +type SyntaxNode = + | Leaf of value : int + | Branch of children : EquatableArray + +/// A record that can hold itself through a mutable field, to show that the display of a cycle stops. +type CyclicNode = { mutable Children : EquatableArray } + +/// An F# struct record as an element type. +[] +type Point = { X : int; Y : int } + +/// A union with a struct tuple array as an element type. +type Shape = + | Circle of radius : float + | Polygon of points : EquatableArray + +/// Arrays nested in arrays, in a value option and in union cases. +type Nested = { + Rows : EquatableArray> + Maybe : EquatableArray voption + Shapes : EquatableArray +} + +/// An element whose hash code records each call, to show which elements a hash reads. +type HashProbe (number : int, asked : List) = + override _.GetHashCode () = + asked.Add number + number + + override this.Equals (other : objnull) = obj.ReferenceEquals (this, other) + +[] +module internal EquatableArrayTestHelpers = + + /// Builds an array from a list, the short form for test data. + let ea (items : 'T list) = EquatableArray.ofList items + + /// The array that an ImmutableArray wraps, to show whether an operation shares or copies it. + let underlying (items : ImmutableArray<'T>) : 'T array | null = ImmutableCollectionsMarshal.AsArray items diff --git a/tests/FSharp.Collections.Immutable.Tests/FSharp.Collections.Immutable.Tests.fsproj b/tests/FSharp.Collections.Immutable.Tests/FSharp.Collections.Immutable.Tests.fsproj index ff9cc792..f05b62ef 100644 --- a/tests/FSharp.Collections.Immutable.Tests/FSharp.Collections.Immutable.Tests.fsproj +++ b/tests/FSharp.Collections.Immutable.Tests/FSharp.Collections.Immutable.Tests.fsproj @@ -23,6 +23,11 @@ $(NoWarn);FS0075 + + + false + + PreserveNewest @@ -63,9 +68,21 @@ + + + + + + + + + + + + diff --git a/tests/FSharp.Collections.Immutable.Tests/TestCategories.fs b/tests/FSharp.Collections.Immutable.Tests/TestCategories.fs index 6c2bbb9e..1690f6ea 100644 --- a/tests/FSharp.Collections.Immutable.Tests/TestCategories.fs +++ b/tests/FSharp.Collections.Immutable.Tests/TestCategories.fs @@ -8,6 +8,13 @@ open Microsoft.VisualStudio.TestTools.UnitTesting // The attributes go on the test classes only, for the collection and the area that a class examines; test methods carry // no categories (ADR 0001, D8). +/// Categorizes a test that counts the bytes an operation allocates. These tests are compiled in Release builds only, +/// because a Debug build does not optimize the code that they measure. +type AllocationTestCategoryAttribute () = + inherit TestCategoryBaseAttribute () + static let categories : IList = [| "Allocation" |] + override _.TestCategories = categories + /// The assertions that extend `Assert` (`Assert.fs`). type AssertExtensionsTestCategoryAttribute () = inherit TestCategoryBaseAttribute () @@ -20,6 +27,12 @@ type BuilderTestCategoryAttribute () = static let categories : IList = [| "Builder" |] override _.TestCategories = categories +/// Categorizes a test of the order of values. +type ComparisonTestCategoryAttribute () = + inherit TestCategoryBaseAttribute () + static let categories : IList = [| "Comparison" |] + override _.TestCategories = categories + /// The computation expressions. type ComputationExpressionTestCategoryAttribute () = inherit TestCategoryBaseAttribute () @@ -32,12 +45,36 @@ type ConventionTestCategoryAttribute () = static let categories : IList = [| "Convention" |] override _.TestCategories = categories +/// Categorizes a test of the default value of a struct collection, which must behave as the empty collection. +type DefaultValueTestCategoryAttribute () = + inherit TestCategoryBaseAttribute () + static let categories : IList = [| "Default value" |] + override _.TestCategories = categories + +/// Categorizes a test of equality and hash codes. +type EqualityTestCategoryAttribute () = + inherit TestCategoryBaseAttribute () + static let categories : IList = [| "Equality" |] + override _.TestCategories = categories + +/// Categorizes a test of . +type EquatableArrayTestCategoryAttribute () = + inherit TestCategoryBaseAttribute () + static let categories : IList = [| "EquatableArray" |] + override _.TestCategories = categories + /// The `FlatList` module (`ImmutableArray`). type FlatListTestCategoryAttribute () = inherit TestCategoryBaseAttribute () static let categories : IList = [| "FlatList" |] override _.TestCategories = categories +/// Categorizes a test of the text that %A and ToString give for a value. +type FormattingTestCategoryAttribute () = + inherit TestCategoryBaseAttribute () + static let categories : IList = [| "Formatting" |] + override _.TestCategories = categories + /// The `FrozenMap` module (`FrozenDictionary`). type FrozenMapTestCategoryAttribute () = inherit TestCategoryBaseAttribute () @@ -98,6 +135,24 @@ type IndexedListTestCategoryAttribute () = static let categories : IList = [| "IndexedList" |] override _.TestCategories = categories +/// Categorizes a test of the members that .NET interfaces, conversions, spans and C# use. +type InteropTestCategoryAttribute () = + inherit TestCategoryBaseAttribute () + static let categories : IList = [| "Interop" |] + override _.TestCategories = categories + +/// Categorizes a test of the conversion to and from JSON. +type JsonTestCategoryAttribute () = + inherit TestCategoryBaseAttribute () + static let categories : IList = [| "JSON" |] + override _.TestCategories = categories + +/// Categorizes a test of the functions of a collection module. +type ModuleFunctionsTestCategoryAttribute () = + inherit TestCategoryBaseAttribute () + static let categories : IList = [| "Module functions" |] + override _.TestCategories = categories + /// The modules of the `FSharp.Collections.Immutable.Option` namespace. type OptionNamespaceTestCategoryAttribute () = inherit TestCategoryBaseAttribute ()