diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..1c5550f
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,93 @@
+root = true
+
+[*]
+charset = utf-8
+end_of_line = lf
+insert_final_newline = true
+trim_trailing_whitespace = true
+
+[*.csproj]
+indent_size = 2
+indent_style = space
+
+[*.yml]
+indent_size = 2
+indent_style = space
+
+[*.sln]
+end_of_line = crlf
+indent_size = 4
+indent_style = tab
+
+[*.md]
+indent_size = 4
+indent_style = space
+
+# CSharp code style settings:
+[*.cs]
+indent_size = 4
+indent_style = space
+
+# Prefer "var" everywhere
+csharp_style_var_for_built_in_types = true:suggestion
+csharp_style_var_when_type_is_apparent = true:suggestion
+csharp_style_var_elsewhere = true:suggestion
+
+# Prefer "this" on fields
+dotnet_style_qualification_for_field = true:error
+dotnet_style_qualification_for_property = false
+dotnet_style_qualification_for_method = false
+dotnet_style_qualification_for_event = false
+
+# No "private" fields.
+dotnet_style_require_accessibility_modifiers = omit_if_default:error
+
+# Prefer method-like constructs to have a block body
+csharp_style_expression_bodied_methods = false:none
+csharp_style_expression_bodied_constructors = false:none
+csharp_style_expression_bodied_operators = false:none
+
+# Prefer property-like constructs to have an expression-body
+csharp_style_expression_bodied_properties = true:none
+csharp_style_expression_bodied_indexers = true:none
+csharp_style_expression_bodied_accessors = true:none
+csharp_style_expression_bodied_lambdas = true:silent
+csharp_style_expression_bodied_local_functions = false:silent
+
+# Use braces but not for "using"
+csharp_prefer_braces = true:error
+csharp_prefer_simple_using_statement = true:suggestion
+
+# Newline settings
+csharp_new_line_before_open_brace = none
+csharp_new_line_before_else = false
+csharp_new_line_before_catch = false
+csharp_new_line_before_finally = false
+
+# Namespace and using and whitespace
+csharp_indent_labels = one_less_than_current
+csharp_space_around_binary_operators = before_and_after
+csharp_style_namespace_declarations = file_scoped:silent
+csharp_using_directive_placement = outside_namespace:silent
+
+# Suggest more modern language features when available
+csharp_prefer_simple_default_expression = true:suggestion
+csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion
+csharp_style_pattern_matching_over_as_with_null_check = true:suggestion
+csharp_style_inlined_variable_declaration = true:suggestion
+csharp_style_throw_expression = true:suggestion
+csharp_style_conditional_delegate_call = true:suggestion
+
+csharp_style_prefer_method_group_conversion = true:silent
+csharp_style_prefer_top_level_statements = true:silent
+csharp_style_prefer_index_operator = true:suggestion
+csharp_style_prefer_local_over_anonymous_function = true:suggestion
+csharp_style_prefer_null_check_over_type_check = true:suggestion
+csharp_style_prefer_range_operator = true:suggestion
+csharp_style_prefer_tuple_swap = true:suggestion
+csharp_style_prefer_utf8_string_literals = true:suggestion
+
+csharp_style_deconstructed_variable_declaration = true:suggestion
+csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion
+csharp_style_unused_value_assignment_preference = discard_variable:suggestion
+csharp_style_unused_value_expression_statement_preference = discard_variable:silent
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..59c3457
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,2 @@
+* text eol=lf
+*.sln text eol=crlf
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
new file mode 100644
index 0000000..969b5fd
--- /dev/null
+++ b/.github/workflows/build.yml
@@ -0,0 +1,19 @@
+name: Build .NET assemblies and test
+
+on:
+ push:
+ branches: [ "master" ]
+ pull_request:
+ branches: [ "master" ]
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: 8.0.x
+ - run: dotnet restore
+ - run: dotnet build --no-restore
+ - run: dotnet test --no-build --verbosity normal
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..108c05f
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,36 @@
+name: Upload .NET package
+
+on:
+ release:
+ types: [created]
+
+jobs:
+ release:
+ runs-on: ubuntu-latest
+ permissions:
+ packages: write
+ contents: read
+ env:
+ version: ${{ github.event.release.tag_name }}
+ steps:
+ - name: Checkout source
+ uses: actions/checkout@v4
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: 8.0.x
+ - name: Build and pack
+ run: |
+ echo $version
+ echo ${version:1}
+ case $version in v*) true;; *) echo "Git tag must start with a 'v'"; false;; esac
+ export version=${version:1}
+ echo $version
+ dotnet build /p:VersionPrefix=$version --configuration Release Xledger.Sql
+ dotnet pack /p:VersionPrefix=$version --configuration Release Xledger.Sql
+ # https://learn.microsoft.com/en-us/nuget/quickstart/create-and-publish-a-package-using-the-dotnet-cli
+ - name: Push package to nuget
+ run: dotnet nuget push Xledger.Sql/bin/Release/*.nupkg --api-key "$NUGET_API_KEY" --source https://api.nuget.org/v3/index.json
+ # run: dotnet nuget push Xledger.Sql/bin/Release/*.nupkg --api-key "$NUGET_API_KEY" --source https://apiint.nugettest.org/v3/index.json
+ env:
+ NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }}
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..dfcfd56
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,350 @@
+## Ignore Visual Studio temporary files, build results, and
+## files generated by popular Visual Studio add-ons.
+##
+## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
+
+# User-specific files
+*.rsuser
+*.suo
+*.user
+*.userosscache
+*.sln.docstates
+
+# User-specific files (MonoDevelop/Xamarin Studio)
+*.userprefs
+
+# Mono auto generated files
+mono_crash.*
+
+# Build results
+[Dd]ebug/
+[Dd]ebugPublic/
+[Rr]elease/
+[Rr]eleases/
+x64/
+x86/
+[Aa][Rr][Mm]/
+[Aa][Rr][Mm]64/
+bld/
+[Bb]in/
+[Oo]bj/
+[Ll]og/
+[Ll]ogs/
+
+# Visual Studio 2015/2017 cache/options directory
+.vs/
+# Uncomment if you have tasks that create the project's static files in wwwroot
+#wwwroot/
+
+# Visual Studio 2017 auto generated files
+Generated\ Files/
+
+# MSTest test Results
+[Tt]est[Rr]esult*/
+[Bb]uild[Ll]og.*
+
+# NUnit
+*.VisualState.xml
+TestResult.xml
+nunit-*.xml
+
+# Build Results of an ATL Project
+[Dd]ebugPS/
+[Rr]eleasePS/
+dlldata.c
+
+# Benchmark Results
+BenchmarkDotNet.Artifacts/
+
+# .NET Core
+project.lock.json
+project.fragment.lock.json
+artifacts/
+
+# StyleCop
+StyleCopReport.xml
+
+# Files built by Visual Studio
+*_i.c
+*_p.c
+*_h.h
+*.ilk
+*.meta
+*.obj
+*.iobj
+*.pch
+*.pdb
+*.ipdb
+*.pgc
+*.pgd
+*.rsp
+*.sbr
+*.tlb
+*.tli
+*.tlh
+*.tmp
+*.tmp_proj
+*_wpftmp.csproj
+*.log
+*.vspscc
+*.vssscc
+.builds
+*.pidb
+*.svclog
+*.scc
+
+# Chutzpah Test files
+_Chutzpah*
+
+# Visual C++ cache files
+ipch/
+*.aps
+*.ncb
+*.opendb
+*.opensdf
+*.sdf
+*.cachefile
+*.VC.db
+*.VC.VC.opendb
+
+# Visual Studio profiler
+*.psess
+*.vsp
+*.vspx
+*.sap
+
+# Visual Studio Trace Files
+*.e2e
+
+# TFS 2012 Local Workspace
+$tf/
+
+# Guidance Automation Toolkit
+*.gpState
+
+# ReSharper is a .NET coding add-in
+_ReSharper*/
+*.[Rr]e[Ss]harper
+*.DotSettings.user
+
+# TeamCity is a build add-in
+_TeamCity*
+
+# DotCover is a Code Coverage Tool
+*.dotCover
+
+# AxoCover is a Code Coverage Tool
+.axoCover/*
+!.axoCover/settings.json
+
+# Visual Studio code coverage results
+*.coverage
+*.coveragexml
+
+# NCrunch
+_NCrunch_*
+.*crunch*.local.xml
+nCrunchTemp_*
+
+# MightyMoose
+*.mm.*
+AutoTest.Net/
+
+# Web workbench (sass)
+.sass-cache/
+
+# Installshield output folder
+[Ee]xpress/
+
+# DocProject is a documentation generator add-in
+DocProject/buildhelp/
+DocProject/Help/*.HxT
+DocProject/Help/*.HxC
+DocProject/Help/*.hhc
+DocProject/Help/*.hhk
+DocProject/Help/*.hhp
+DocProject/Help/Html2
+DocProject/Help/html
+
+# Click-Once directory
+publish/
+
+# Publish Web Output
+*.[Pp]ublish.xml
+*.azurePubxml
+# Note: Comment the next line if you want to checkin your web deploy settings,
+# but database connection strings (with potential passwords) will be unencrypted
+*.pubxml
+*.publishproj
+
+# Microsoft Azure Web App publish settings. Comment the next line if you want to
+# checkin your Azure Web App publish settings, but sensitive information contained
+# in these scripts will be unencrypted
+PublishScripts/
+
+# NuGet Packages
+*.nupkg
+# NuGet Symbol Packages
+*.snupkg
+# The packages folder can be ignored because of Package Restore
+**/[Pp]ackages/*
+# except build/, which is used as an MSBuild target.
+!**/[Pp]ackages/build/
+# Uncomment if necessary however generally it will be regenerated when needed
+#!**/[Pp]ackages/repositories.config
+# NuGet v3's project.json files produces more ignorable files
+*.nuget.props
+*.nuget.targets
+
+# Microsoft Azure Build Output
+csx/
+*.build.csdef
+
+# Microsoft Azure Emulator
+ecf/
+rcf/
+
+# Windows Store app package directories and files
+AppPackages/
+BundleArtifacts/
+Package.StoreAssociation.xml
+_pkginfo.txt
+*.appx
+*.appxbundle
+*.appxupload
+
+# Visual Studio cache files
+# files ending in .cache can be ignored
+*.[Cc]ache
+# but keep track of directories ending in .cache
+!?*.[Cc]ache/
+
+# Others
+ClientBin/
+~$*
+*~
+*.dbmdl
+*.dbproj.schemaview
+*.jfm
+*.pfx
+*.publishsettings
+orleans.codegen.cs
+
+# Including strong name files can present a security risk
+# (https://github.com/github/gitignore/pull/2483#issue-259490424)
+#*.snk
+
+# Since there are multiple workflows, uncomment next line to ignore bower_components
+# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
+#bower_components/
+
+# RIA/Silverlight projects
+Generated_Code/
+
+# Backup & report files from converting an old project file
+# to a newer Visual Studio version. Backup files are not needed,
+# because we have git ;-)
+_UpgradeReport_Files/
+Backup*/
+UpgradeLog*.XML
+UpgradeLog*.htm
+ServiceFabricBackup/
+*.rptproj.bak
+
+# SQL Server files
+*.mdf
+*.ldf
+*.ndf
+
+# Business Intelligence projects
+*.rdl.data
+*.bim.layout
+*.bim_*.settings
+*.rptproj.rsuser
+*- [Bb]ackup.rdl
+*- [Bb]ackup ([0-9]).rdl
+*- [Bb]ackup ([0-9][0-9]).rdl
+
+# Microsoft Fakes
+FakesAssemblies/
+
+# GhostDoc plugin setting file
+*.GhostDoc.xml
+
+# Node.js Tools for Visual Studio
+.ntvs_analysis.dat
+node_modules/
+
+# Visual Studio 6 build log
+*.plg
+
+# Visual Studio 6 workspace options file
+*.opt
+
+# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
+*.vbw
+
+# Visual Studio LightSwitch build output
+**/*.HTMLClient/GeneratedArtifacts
+**/*.DesktopClient/GeneratedArtifacts
+**/*.DesktopClient/ModelManifest.xml
+**/*.Server/GeneratedArtifacts
+**/*.Server/ModelManifest.xml
+_Pvt_Extensions
+
+# Paket dependency manager
+.paket/paket.exe
+paket-files/
+
+# FAKE - F# Make
+.fake/
+
+# CodeRush personal settings
+.cr/personal
+
+# Python Tools for Visual Studio (PTVS)
+__pycache__/
+*.pyc
+
+# Cake - Uncomment if you are using it
+# tools/**
+# !tools/packages.config
+
+# Tabs Studio
+*.tss
+
+# Telerik's JustMock configuration file
+*.jmconfig
+
+# BizTalk build output
+*.btp.cs
+*.btm.cs
+*.odx.cs
+*.xsd.cs
+
+# OpenCover UI analysis results
+OpenCover/
+
+# Azure Stream Analytics local run output
+ASALocalRun/
+
+# MSBuild Binary and Structured Log
+*.binlog
+
+# NVidia Nsight GPU debugger configuration file
+*.nvuser
+
+# MFractors (Xamarin productivity tool) working folder
+.mfractor/
+
+# Local History for Visual Studio
+.localhistory/
+
+# BeatPulse healthcheck temp database
+healthchecksdb
+
+# Backup folder for Package Reference Convert tool in Visual Studio 2017
+MigrationBackup/
+
+# Ionide (cross platform F# VS Code tools) working folder
+.ionide/
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..f337105
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2024 Xledger
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..30b32c2
--- /dev/null
+++ b/README.md
@@ -0,0 +1,16 @@
+# Xledger.Collections
+
+[![NuGet version (Xledger.Collections)](https://img.shields.io/nuget/v/Xledger.Collections.svg?style=flat-square)](https://www.nuget.org/packages/Xledger.Collections/)
+
+Xledger.Collections provides immutable and equatable collections.
+
+## Running the Tests
+
+```powershell
+dotnet test Xledger.Collections.Test
+```
+
+To list the available tests:
+```powershell
+dotnet test Xledger.Collections.Test --list-tests
+```
diff --git a/Xledger.Collections.Test/GlobalSuppressions.cs b/Xledger.Collections.Test/GlobalSuppressions.cs
new file mode 100644
index 0000000..6bc30c5
--- /dev/null
+++ b/Xledger.Collections.Test/GlobalSuppressions.cs
@@ -0,0 +1,10 @@
+// This file is used by Code Analysis to maintain SuppressMessage
+// attributes that are applied to this project.
+// Project-level suppressions either have no target or are given
+// a specific target and scoped to a namespace, type, member, etc.
+
+using System.Diagnostics.CodeAnalysis;
+
+[assembly: SuppressMessage("Assertions", "xUnit2013:Do not use equality check to check for collection size.", Justification = "This is testing the entire collection interface.", Scope = "namespaceanddescendants", Target = "~N:Xledger.Collections.Test")]
+[assembly: SuppressMessage("Assertions", "xUnit2017:Do not use Contains() to check if a value exists in a collection", Justification = "This is testing the entire collection interface.", Scope = "namespaceanddescendants", Target = "~N:Xledger.Collections.Test")]
+[assembly: SuppressMessage("Style", "IDE0130:Namespace does not match folder structure", Justification = "Polyfill.>", Scope = "namespace", Target = "~N:System.Runtime.CompilerServices")]
diff --git a/Xledger.Collections.Test/IsExternalInit.cs b/Xledger.Collections.Test/IsExternalInit.cs
new file mode 100644
index 0000000..f661358
--- /dev/null
+++ b/Xledger.Collections.Test/IsExternalInit.cs
@@ -0,0 +1,11 @@
+#if NETFRAMEWORK
+namespace System.Runtime.CompilerServices;
+///
+/// Allows compiling init properties for .NET Framework.
+/// https://developercommunity.visualstudio.com/t/error-cs0518-predefined-type-systemruntimecompiler/1244809
+/// https://stackoverflow.com/questions/64749385/predefined-type-system-runtime-compilerservices-isexternalinit-is-not-defined
+/// https://stackoverflow.com/questions/62648189/testing-c-sharp-9-0-in-vs2019-cs0518-isexternalinit-is-not-defined-or-imported
+///
+[ComponentModel.EditorBrowsable(ComponentModel.EditorBrowsableState.Never)]
+static class IsExternalInit { }
+#endif
diff --git a/Xledger.Collections.Test/TestImmArray.cs b/Xledger.Collections.Test/TestImmArray.cs
new file mode 100644
index 0000000..39332f1
--- /dev/null
+++ b/Xledger.Collections.Test/TestImmArray.cs
@@ -0,0 +1,142 @@
+namespace Xledger.Collections.Test;
+
+public class TestImmArray {
+ [Fact]
+ public void TestEmpty() {
+ var imm = ImmArray.Empty;
+ Assert.Empty(imm);
+ Assert.Equal(0, imm.Count);
+ Assert.False(imm.GetEnumerator().MoveNext());
+ Assert.Equal(imm.GetHashCode(), new ImmArray().GetHashCode());
+ Assert.Equal(imm, new ImmArray());
+ Assert.Equal(imm, imm);
+ Assert.Equal(new ImmArray(), imm);
+ Assert.Equal("[]", imm.ToString());
+
+ Assert.False(imm.Equals((object)null));
+ Assert.False(imm.Equals((ImmArray)null));
+ }
+
+ [Fact]
+ public void TestSingle() {
+ var imm1 = new ImmArray([1]);
+ Assert.NotEmpty(imm1);
+ Assert.Equal(1, imm1.Count);
+ Assert.Equal(imm1, imm1);
+ Assert.True(imm1.GetEnumerator().MoveNext());
+ Assert.Equal("[1]", imm1.ToString());
+
+ var imm2 = new ImmArray([1]);
+ Assert.Equal(imm1.GetHashCode(), imm2.GetHashCode());
+ Assert.Equal(imm1, imm2);
+ Assert.Equal(imm2, imm1);
+ }
+
+ [Fact]
+ public void TestCopy() {
+ int[] arr = [1, 2, 3, 4, 5, 6];
+ var lst = new List(arr);
+ var imm = arr.ToImmArray();
+
+ int[] target1 = new int[100];
+ lst.CopyTo(target1);
+ lst.CopyTo(target1, 12);
+ lst.CopyTo(2, target1, 30, 3);
+
+ int[] target2 = new int[100];
+ imm.CopyTo(target2);
+ imm.CopyTo(target2, 12);
+ imm.CopyTo(2, target2, 30, 3);
+
+ Assert.Equal(target1, target2);
+
+ target1 = lst.ToArray();
+ target1[0] = 99;
+ Assert.NotEqual(lst, target1);
+
+ target2 = imm.ToArray();
+ target2[0] = 99;
+ Assert.NotEqual(lst, target2);
+ }
+
+ [Fact]
+ public void TestContains() {
+ int[] arr = [1, 2, 3, 1, 2, 2];
+ var lst = new List(arr);
+ var imm = arr.ToImmArray();
+
+ Assert.Equal(0, lst.IndexOf(1));
+ Assert.Equal(1, lst.IndexOf(2));
+ Assert.Equal(2, lst.IndexOf(3));
+ Assert.False(lst.Contains(9));
+ Assert.True(lst.Contains(3));
+
+ Assert.Equal(0, imm.IndexOf(1));
+ Assert.Equal(1, imm.IndexOf(2));
+ Assert.Equal(2, imm.IndexOf(3));
+ Assert.False(imm.Contains(9));
+ Assert.True(imm.Contains(3));
+
+ Assert.Equal(lst.IndexOf(-1), imm.IndexOf(-1));
+ Assert.Equal(lst.IndexOf(0), imm.IndexOf(0));
+ Assert.Equal(lst.IndexOf(1), imm.IndexOf(1));
+ Assert.Equal(lst.IndexOf(2), imm.IndexOf(2));
+ Assert.Equal(lst.IndexOf(3), imm.IndexOf(3));
+ Assert.Equal(lst.IndexOf(9), imm.IndexOf(9));
+ }
+
+ [Fact]
+ public void TestNoOps() {
+ var imm = ImmArray.Of(7, 6, 5, 4);
+ IList ilist = imm;
+ Assert.ThrowsAny(() => ilist[0] = 1);
+ Assert.ThrowsAny(() => ilist.Add(1));
+ Assert.ThrowsAny(() => ilist.Clear());
+ Assert.ThrowsAny(() => ilist.Insert(1, 2));
+ Assert.ThrowsAny(() => ilist.Remove(4));
+ Assert.ThrowsAny(() => ilist.RemoveAt(2));
+ Assert.ThrowsAny(() => ((System.Collections.ICollection)ilist).CopyTo((Array)null, 0));
+ }
+
+ [Fact]
+ public void TestCompareTo() {
+ Assert.True(null <= (ImmArray)null);
+
+ var imm1 = ImmArray.Of("foo", "bar", "baz");
+ Assert.True(null < imm1);
+ Assert.True(null <= imm1);
+ Assert.True(imm1 > null);
+ Assert.True(imm1 >= null);
+
+ var imm2 = ImmArray.Of("foo", "bar", "zip");
+ Assert.True(imm1 < imm2);
+ Assert.True(imm1 <= imm2);
+ Assert.True(imm2 > imm1);
+ Assert.True(imm2 >= imm1);
+ }
+
+
+ [Fact]
+ public void TestCompareToRecord() {
+ var imm1 = ImmArray.Of(
+ new Employee(1, "Bas Rutten", 10_000),
+ new Employee(2, "Conor McGregor", 200_000),
+ new Employee(3, "Frank Shamrock", 6_000));
+ Assert.True(null < imm1);
+ Assert.True(null <= imm1);
+ Assert.True(imm1 > null);
+ Assert.True(imm1 >= null);
+
+ var imm2 = ImmArray.Of(
+ new Employee(1, "Bas Rutten", 10_000),
+ new Employee(2, "Conor McGregor", 200_000),
+ new Employee(3, "Frank Shamrock", 6_000));
+ Assert.ThrowsAny(() => imm1 < imm2);
+ Assert.ThrowsAny(() => imm1 <= imm2);
+ Assert.ThrowsAny(() => imm2 > imm1);
+ Assert.ThrowsAny(() => imm2 >= imm1);
+ }
+
+ public record Employee(int Id, string Name, decimal Salary);
+}
+
diff --git a/Xledger.Collections.Test/TestImmDict.cs b/Xledger.Collections.Test/TestImmDict.cs
new file mode 100644
index 0000000..1cc1466
--- /dev/null
+++ b/Xledger.Collections.Test/TestImmDict.cs
@@ -0,0 +1,76 @@
+namespace Xledger.Collections.Test;
+
+public class TestImmDict {
+ [Fact]
+ public void TestEmpty() {
+ var imm = ImmDict.Empty;
+ Assert.Empty(imm);
+ Assert.Equal(0, imm.Count);
+ Assert.False(imm.GetEnumerator().MoveNext());
+ Assert.Equal(imm.GetHashCode(), new ImmDict().GetHashCode());
+ Assert.Equal(imm, new ImmDict());
+ Assert.Equal(imm, imm);
+ Assert.Equal(new ImmDict(), imm);
+ Assert.Equal("[]", imm.ToString());
+
+ Assert.False(imm.Equals((object)null));
+ Assert.False(imm.Equals((ImmDict)null));
+ }
+
+ [Fact]
+ public void TestSingle() {
+ var imm1 = new ImmDict(new Dictionary {
+ [1] = "foo",
+ });
+ Assert.NotEmpty(imm1);
+ Assert.Equal(1, imm1.Count);
+ Assert.Equal(imm1, imm1);
+ Assert.True(imm1.GetEnumerator().MoveNext());
+ Assert.Equal("[1: foo]", imm1.ToString());
+
+ var imm2 = new ImmDict(new Dictionary {
+ [1] = "foo",
+ });
+ Assert.Equal(imm1.GetHashCode(), imm2.GetHashCode());
+ Assert.Equal(imm1, imm2);
+ Assert.Equal(imm2, imm1);
+ }
+
+ [Fact]
+ public void TestContains() {
+ var items = Enumerable.Range(-100, 1_000).ToDictionary(i => i, i => i * i);
+ var dct = items;
+ var imm = items.ToImmDict();
+
+ for (int i = -1000; i < 2000; ++i) {
+ Assert.Equal(dct.ContainsKey(i), imm.ContainsKey(i));
+ if (dct.TryGetValue(i, out var dctValue)) {
+ imm.TryGetValue(i, out var immValue);
+ Assert.Equal(dctValue, immValue);
+ }
+ }
+ }
+
+ [Fact]
+ public void TestHashCode() {
+ var items = Enumerable.Range(-100, 1_000).ToDictionary(i => i.ToString(), i => (i * i).ToString() + "A");
+ var imm = items.ToImmDict();
+ var ritems = Enumerable.Range(-100, 1_000).Reverse().ToDictionary(i => i.ToString(), i => (i * i).ToString() + "A");
+ var two = ritems.ToImmDict();
+
+ Assert.Equal(imm.GetHashCode(), two.GetHashCode());
+ Assert.True(imm == two);
+ }
+
+ [Fact]
+ public void TestNoOps() {
+ var imm = ImmDict.Of((7, 6), (5, 4));
+ IDictionary idict = imm;
+ Assert.ThrowsAny(() => idict.Add(1, 2));
+ Assert.ThrowsAny(() => idict.Clear());
+ Assert.ThrowsAny(() => idict.Remove(4));
+ Assert.ThrowsAny(() => ((System.Collections.ICollection)idict).CopyTo((Array)null, 0));
+ }
+}
+
+
diff --git a/Xledger.Collections.Test/TestImmSet.cs b/Xledger.Collections.Test/TestImmSet.cs
new file mode 100644
index 0000000..6bd2f69
--- /dev/null
+++ b/Xledger.Collections.Test/TestImmSet.cs
@@ -0,0 +1,84 @@
+namespace Xledger.Collections.Test;
+
+public class TestImmSet{
+ [Fact]
+ public void TestEmpty() {
+ var imm = ImmSet.Empty;
+ Assert.Empty(imm);
+ Assert.Equal(0, imm.Count);
+ Assert.False(imm.GetEnumerator().MoveNext());
+ Assert.Equal(imm.GetHashCode(), new ImmSet().GetHashCode());
+ Assert.Equal(imm, new ImmSet());
+ Assert.Equal(imm, imm);
+ Assert.Equal(new ImmSet(), imm);
+ Assert.Equal("[]", imm.ToString());
+
+ Assert.False(imm.Equals((object)null));
+ Assert.False(imm.Equals((ImmSet)null));
+ }
+
+ [Fact]
+ public void TestSingle() {
+ var imm1 = new ImmSet([1]);
+ Assert.NotEmpty(imm1);
+ Assert.Equal(1, imm1.Count);
+ Assert.Equal(imm1, imm1);
+ Assert.True(imm1.GetEnumerator().MoveNext());
+ Assert.Equal("[1]", imm1.ToString());
+
+ var imm2 = new ImmSet([1]);
+ Assert.Equal(imm1.GetHashCode(), imm2.GetHashCode());
+ Assert.Equal(imm1, imm2);
+ Assert.Equal(imm2, imm1);
+ }
+
+ [Fact]
+ public void TestCopy() {
+ int[] arr = [1, 2, 3, 4, 5, 6];
+ var hsh = new HashSet(arr);
+ var imm = arr.ToImmSet();
+
+ int[] target1 = new int[100];
+ hsh.CopyTo(target1);
+ hsh.CopyTo(target1, 12);
+
+ int[] target2 = new int[100];
+ imm.CopyTo(target2);
+ imm.CopyTo(target2, 12);
+
+ Assert.Equal(target1, target2);
+
+ target1 = hsh.ToArray();
+ target1[0] = 99;
+ Assert.NotEqual(hsh, target1);
+
+ target2 = imm.ToArray();
+ target2[0] = 99;
+ Assert.NotEqual(hsh, target2);
+ }
+
+ [Fact]
+ public void TestContains() {
+ int[] arr = [1, 2, 3, 1, 2, 2];
+ var lst = new List(arr);
+ var imm = arr.ToImmSet();
+
+ Assert.Equal(0, lst.IndexOf(1));
+ Assert.Equal(1, lst.IndexOf(2));
+ Assert.Equal(2, lst.IndexOf(3));
+ Assert.False(lst.Contains(9));
+ Assert.True(lst.Contains(3));
+ }
+
+ [Fact]
+ public void TestNoOps() {
+ var imm = ImmSet.Of(7, 6, 5, 4);
+ ISet iset = imm;
+ Assert.ThrowsAny(() => iset.Add(1));
+ Assert.ThrowsAny(() => iset.Clear());
+ Assert.ThrowsAny(() => iset.Remove(4));
+ Assert.ThrowsAny(() => ((System.Collections.ICollection)iset).CopyTo((Array)null, 0));
+ }
+}
+
+
diff --git a/Xledger.Collections.Test/TestSerialization.cs b/Xledger.Collections.Test/TestSerialization.cs
new file mode 100644
index 0000000..46c41dd
--- /dev/null
+++ b/Xledger.Collections.Test/TestSerialization.cs
@@ -0,0 +1,130 @@
+namespace Xledger.Collections.Test;
+
+public class TestSerialization {
+ static readonly System.Text.UTF8Encoding UTF8 = new(false);
+
+ public delegate byte[] Serialize(T obj);
+ public delegate T Deserialize(byte[] s);
+
+ static readonly IEnumerable Ints = [
+ [1, 31, 23, 3, 45, 66, 1038, 9, 1, 31, 66],
+ null,
+ [-2],
+ ];
+
+ // .Distinct() here because .ToDictionary() Add's keys which disallows duplicates.
+ static readonly IEnumerable<(string, int)[]> StringsInts =
+ Ints.Select(ints => ints?.Distinct().Select(i => (i.ToString(), i)).ToArray());
+
+ static IEnumerable<(Serialize s, Deserialize d)> Serializers() => [
+ (NewtonsoftSerialize, NewtonsoftDeserialize),
+ // TODO: Not sure how to get System.Text.Json to deserialize these collection
+ // TODO: types without adding a dependency onto it. For now, just verify that
+ // TODO: the JSON emitted by System.Text.Json is readable by Newtonsoft.Json.
+ (SystemTextSerialize, NewtonsoftDeserialize),
+ //(SystemTextSerialize, SystemTextDeserialize),
+ (BinaryFormatterSerialize, BinaryFormatterDeserialize),
+ ];
+
+ public static readonly IEnumerable