From 4b7bd1f3ab5555bc8fa109d9b6cfde692c427545 Mon Sep 17 00:00:00 2001 From: Andreas Gullberg Larsen Date: Thu, 23 Jul 2026 00:40:16 +0200 Subject: [PATCH 1/5] Allow configuring global default quantities Extracted from angularsen/UnitsNet#1544 (https://github.com/angularsen/UnitsNet/pull/1544) because choosing the global quantity catalog is an independent setup capability, not a requirement of fractional QuantityValue. Keeping it separate makes the singleton initialization and compatibility implications explicit and allows this commit to be deferred without blocking the isolated factory APIs. Changes: - Lazily create UnitsNetSetup.Default from the existing setup builder. - Add ConfigureDefaults for selecting the global catalog before first use. - Synchronize configuration and creation so concurrent first access cannot observe a partially configured builder. - Reject configuration after the singleton has been created. Tests: - Add a dedicated test assembly so global singleton state is isolated from the existing suite. - Verify configured catalog selection, default cache/parser wiring, excluded units, and rejection of reconfiguration. - Run the regression on net10.0 and net48 (1 test per target). --- .../UnitsNet.GlobalSetup.Tests.csproj | 32 +++++++++++++ .../UnitsNetSetupGlobalConfigurationTests.cs | 23 +++++++++ UnitsNet.slnx | 1 + UnitsNet/CustomCode/UnitsNetSetup.cs | 48 +++++++++++++++---- 4 files changed, 96 insertions(+), 8 deletions(-) create mode 100644 UnitsNet.GlobalSetup.Tests/UnitsNet.GlobalSetup.Tests.csproj create mode 100644 UnitsNet.GlobalSetup.Tests/UnitsNetSetupGlobalConfigurationTests.cs diff --git a/UnitsNet.GlobalSetup.Tests/UnitsNet.GlobalSetup.Tests.csproj b/UnitsNet.GlobalSetup.Tests/UnitsNet.GlobalSetup.Tests.csproj new file mode 100644 index 0000000000..d4b2b5d35d --- /dev/null +++ b/UnitsNet.GlobalSetup.Tests/UnitsNet.GlobalSetup.Tests.csproj @@ -0,0 +1,32 @@ + + + + net10.0 + $(TargetFrameworks);net48 + latest + enable + true + enable + xunit + + + + ../UnitsNet.snk + false + true + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + diff --git a/UnitsNet.GlobalSetup.Tests/UnitsNetSetupGlobalConfigurationTests.cs b/UnitsNet.GlobalSetup.Tests/UnitsNetSetupGlobalConfigurationTests.cs new file mode 100644 index 0000000000..129497bda7 --- /dev/null +++ b/UnitsNet.GlobalSetup.Tests/UnitsNetSetupGlobalConfigurationTests.cs @@ -0,0 +1,23 @@ +// Licensed under MIT No Attribution, see LICENSE file at the root. +// Copyright 2013 Andreas Gullberg Larsen (andreas.larsen84@gmail.com). Maintained at https://github.com/angularsen/UnitsNet. + +using UnitsNet.Units; +using Xunit; + +namespace UnitsNet.GlobalSetup.Tests; + +public class UnitsNetSetupGlobalConfigurationTests +{ + [Fact] + public void ConfigureDefaults_BeforeFirstUse_ConfiguresAndFreezesDefaultSetup() + { + UnitsNetSetup configured = UnitsNetSetup.ConfigureDefaults(builder => builder.WithQuantities([Mass.Info])); + + Assert.Same(configured, UnitsNetSetup.Default); + Assert.Same(configured.UnitAbbreviations, UnitAbbreviationsCache.Default); + Assert.Same(configured.UnitParser, UnitParser.Default); + Assert.Equal([Mass.Info], configured.Quantities.Infos); + Assert.Throws(() => configured.UnitParser.Parse("m")); + Assert.Throws(() => UnitsNetSetup.ConfigureDefaults(_ => { })); + } +} diff --git a/UnitsNet.slnx b/UnitsNet.slnx index 7c14e04985..25ae9c2a5b 100644 --- a/UnitsNet.slnx +++ b/UnitsNet.slnx @@ -43,6 +43,7 @@ + diff --git a/UnitsNet/CustomCode/UnitsNetSetup.cs b/UnitsNet/CustomCode/UnitsNetSetup.cs index 4e20412cef..4fa24e95f8 100644 --- a/UnitsNet/CustomCode/UnitsNetSetup.cs +++ b/UnitsNet/CustomCode/UnitsNetSetup.cs @@ -14,6 +14,10 @@ namespace UnitsNet; /// public sealed class UnitsNetSetup { + private static readonly object DefaultConfigurationLock = new(); + private static DefaultConfigurationBuilder _defaultConfigurationBuilder = new(); + private static readonly Lazy DefaultConfiguration = new(BuildDefault); + /// /// Builds a UnitsNet setup by selecting built-in or external quantity definitions. /// @@ -92,14 +96,12 @@ internal UnitsNetSetup Build() } } - static UnitsNetSetup() + private static UnitsNetSetup BuildDefault() { - IReadOnlyCollection quantityInfos = Quantity.DefaultProvider.Quantities; - - // note: in order to support the ConvertByAbbreviation, the unit converter should require a UnitParser in the constructor - var unitConverter = UnitConverter.CreateDefault(); - - Default = new UnitsNetSetup(quantityInfos, unitConverter); + lock (DefaultConfigurationLock) + { + return _defaultConfigurationBuilder.Build(); + } } /// @@ -116,6 +118,36 @@ public static UnitsNetSetup Create(Action configura return builder.Build(); } + /// + /// Configures and creates the global default setup before its first use. + /// + /// Configures the quantities included in the default setup. + /// The configured global default setup. + /// The default setup has already been created. + public static UnitsNetSetup ConfigureDefaults(Action configuration) + { + if (configuration is null) throw new ArgumentNullException(nameof(configuration)); + + lock (DefaultConfigurationLock) + { + if (DefaultConfiguration.IsValueCreated) + { + throw new InvalidOperationException("The default configuration cannot be changed after it has been created."); + } + + var builder = new DefaultConfigurationBuilder(); + configuration(builder); + + if (DefaultConfiguration.IsValueCreated) + { + throw new InvalidOperationException("The default configuration was created while it was being configured."); + } + + _defaultConfigurationBuilder = builder; + return DefaultConfiguration.Value; + } + } + /// /// Create a new UnitsNet setup with the given quantities, their units and unit conversion functions between units. /// @@ -144,7 +176,7 @@ public UnitsNetSetup(IEnumerable quantityInfos, UnitConverter unit /// usages of UnitsNet in the /// current AppDomain since the typical use is via static members and not providing a setup instance. /// - public static UnitsNetSetup Default { get; } + public static UnitsNetSetup Default => DefaultConfiguration.Value; /// /// Converts between units of a quantity, such as from meters to centimeters of a given length. From 6466e1e7c577741a1341c5a1c6d06ffa98f6cb34 Mon Sep 17 00:00:00 2001 From: Andreas Gullberg Larsen Date: Thu, 23 Jul 2026 02:18:06 +0200 Subject: [PATCH 2/5] Address global setup review feedback Run the isolated global setup test in net48 CI and clarify the lazy locking, API discoverability, and process-wide test constraint. --- .github/workflows/net48-compatibility.yml | 1 + .../UnitsNetSetupGlobalConfigurationTests.cs | 2 ++ UnitsNet/CustomCode/UnitsNetSetup.cs | 5 +++++ 3 files changed, 8 insertions(+) diff --git a/.github/workflows/net48-compatibility.yml b/.github/workflows/net48-compatibility.yml index 959e57ddc2..097f089f80 100644 --- a/.github/workflows/net48-compatibility.yml +++ b/.github/workflows/net48-compatibility.yml @@ -52,6 +52,7 @@ jobs: run: | $testProjects = @( 'UnitsNet.Tests/UnitsNet.Tests.csproj', + 'UnitsNet.GlobalSetup.Tests/UnitsNet.GlobalSetup.Tests.csproj', 'UnitsNet.NumberExtensions.Tests/UnitsNet.NumberExtensions.Tests.csproj', 'UnitsNet.NumberExtensions.CS14.Tests/UnitsNet.NumberExtensions.CS14.Tests.csproj', 'UnitsNet.Serialization.JsonNet.Tests/UnitsNet.Serialization.JsonNet.Tests.csproj' diff --git a/UnitsNet.GlobalSetup.Tests/UnitsNetSetupGlobalConfigurationTests.cs b/UnitsNet.GlobalSetup.Tests/UnitsNetSetupGlobalConfigurationTests.cs index 129497bda7..846829a5d5 100644 --- a/UnitsNet.GlobalSetup.Tests/UnitsNetSetupGlobalConfigurationTests.cs +++ b/UnitsNet.GlobalSetup.Tests/UnitsNetSetupGlobalConfigurationTests.cs @@ -6,6 +6,8 @@ namespace UnitsNet.GlobalSetup.Tests; +// This assembly intentionally contains a single test because UnitsNetSetup.Default is process-wide and cannot be reset. +// Keep additional global-configuration scenarios isolated in separate test processes. public class UnitsNetSetupGlobalConfigurationTests { [Fact] diff --git a/UnitsNet/CustomCode/UnitsNetSetup.cs b/UnitsNet/CustomCode/UnitsNetSetup.cs index 4fa24e95f8..5b8dcd8a25 100644 --- a/UnitsNet/CustomCode/UnitsNetSetup.cs +++ b/UnitsNet/CustomCode/UnitsNetSetup.cs @@ -14,6 +14,7 @@ namespace UnitsNet; /// public sealed class UnitsNetSetup { + // Lazy synchronizes value creation; this lock also makes the builder swap and creation checks atomic with it. private static readonly object DefaultConfigurationLock = new(); private static DefaultConfigurationBuilder _defaultConfigurationBuilder = new(); private static readonly Lazy DefaultConfiguration = new(BuildDefault); @@ -124,6 +125,7 @@ public static UnitsNetSetup Create(Action configura /// Configures the quantities included in the default setup. /// The configured global default setup. /// The default setup has already been created. + /// public static UnitsNetSetup ConfigureDefaults(Action configuration) { if (configuration is null) throw new ArgumentNullException(nameof(configuration)); @@ -172,10 +174,13 @@ public UnitsNetSetup(IEnumerable quantityInfos, UnitConverter unit /// provided. /// /// + /// Call before first accessing this property to select a different quantity catalog.
+ ///
/// Manipulating this instance, such as adding new units or changing default unit abbreviations, will affect most /// usages of UnitsNet in the /// current AppDomain since the typical use is via static members and not providing a setup instance. ///
+ /// public static UnitsNetSetup Default => DefaultConfiguration.Value; /// From be67226a6b2979786aa28b249d077f8e40ec9302 Mon Sep 17 00:00:00 2001 From: Andreas Gullberg Larsen Date: Thu, 23 Jul 2026 02:21:31 +0200 Subject: [PATCH 3/5] Test configuration after default access Run the default-first scenario in a dedicated test process so the global singleton state is isolated, including on net48 CI. --- .github/workflows/net48-compatibility.yml | 1 + ...sNet.GlobalSetup.DefaultFirst.Tests.csproj | 32 +++++++++++++++++++ .../UnitsNetSetupDefaultFirstTests.cs | 22 +++++++++++++ UnitsNet.slnx | 1 + 4 files changed, 56 insertions(+) create mode 100644 UnitsNet.GlobalSetup.DefaultFirst.Tests/UnitsNet.GlobalSetup.DefaultFirst.Tests.csproj create mode 100644 UnitsNet.GlobalSetup.DefaultFirst.Tests/UnitsNetSetupDefaultFirstTests.cs diff --git a/.github/workflows/net48-compatibility.yml b/.github/workflows/net48-compatibility.yml index 097f089f80..6734ed22da 100644 --- a/.github/workflows/net48-compatibility.yml +++ b/.github/workflows/net48-compatibility.yml @@ -52,6 +52,7 @@ jobs: run: | $testProjects = @( 'UnitsNet.Tests/UnitsNet.Tests.csproj', + 'UnitsNet.GlobalSetup.DefaultFirst.Tests/UnitsNet.GlobalSetup.DefaultFirst.Tests.csproj', 'UnitsNet.GlobalSetup.Tests/UnitsNet.GlobalSetup.Tests.csproj', 'UnitsNet.NumberExtensions.Tests/UnitsNet.NumberExtensions.Tests.csproj', 'UnitsNet.NumberExtensions.CS14.Tests/UnitsNet.NumberExtensions.CS14.Tests.csproj', diff --git a/UnitsNet.GlobalSetup.DefaultFirst.Tests/UnitsNet.GlobalSetup.DefaultFirst.Tests.csproj b/UnitsNet.GlobalSetup.DefaultFirst.Tests/UnitsNet.GlobalSetup.DefaultFirst.Tests.csproj new file mode 100644 index 0000000000..d4b2b5d35d --- /dev/null +++ b/UnitsNet.GlobalSetup.DefaultFirst.Tests/UnitsNet.GlobalSetup.DefaultFirst.Tests.csproj @@ -0,0 +1,32 @@ + + + + net10.0 + $(TargetFrameworks);net48 + latest + enable + true + enable + xunit + + + + ../UnitsNet.snk + false + true + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + diff --git a/UnitsNet.GlobalSetup.DefaultFirst.Tests/UnitsNetSetupDefaultFirstTests.cs b/UnitsNet.GlobalSetup.DefaultFirst.Tests/UnitsNetSetupDefaultFirstTests.cs new file mode 100644 index 0000000000..67218984d9 --- /dev/null +++ b/UnitsNet.GlobalSetup.DefaultFirst.Tests/UnitsNetSetupDefaultFirstTests.cs @@ -0,0 +1,22 @@ +// Licensed under MIT No Attribution, see LICENSE file at the root. +// Copyright 2013 Andreas Gullberg Larsen (andreas.larsen84@gmail.com). Maintained at https://github.com/angularsen/UnitsNet. + +using Xunit; + +namespace UnitsNet.GlobalSetup.DefaultFirst.Tests; + +// This assembly intentionally contains a single test because UnitsNetSetup.Default is process-wide and cannot be reset. +public class UnitsNetSetupDefaultFirstTests +{ + [Fact] + public void ConfigureDefaults_AfterDefaultIsCreated_ThrowsInvalidOperationException() + { + UnitsNetSetup originalDefault = UnitsNetSetup.Default; + bool configurationInvoked = false; + + Assert.Throws(() => UnitsNetSetup.ConfigureDefaults(_ => configurationInvoked = true)); + + Assert.False(configurationInvoked); + Assert.Same(originalDefault, UnitsNetSetup.Default); + } +} diff --git a/UnitsNet.slnx b/UnitsNet.slnx index 25ae9c2a5b..a55c5f6990 100644 --- a/UnitsNet.slnx +++ b/UnitsNet.slnx @@ -43,6 +43,7 @@ + From 4c11e76031d5a2ddc97da0a82bb6efbb04e96846 Mon Sep 17 00:00:00 2001 From: Andreas Gullberg Larsen Date: Thu, 23 Jul 2026 02:22:48 +0200 Subject: [PATCH 4/5] Use XML docs for global setup test constraints --- .../UnitsNetSetupDefaultFirstTests.cs | 7 ++++++- .../UnitsNetSetupGlobalConfigurationTests.cs | 9 +++++++-- UnitsNet/CustomCode/UnitsNetSetup.cs | 4 +++- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/UnitsNet.GlobalSetup.DefaultFirst.Tests/UnitsNetSetupDefaultFirstTests.cs b/UnitsNet.GlobalSetup.DefaultFirst.Tests/UnitsNetSetupDefaultFirstTests.cs index 67218984d9..252f55db61 100644 --- a/UnitsNet.GlobalSetup.DefaultFirst.Tests/UnitsNetSetupDefaultFirstTests.cs +++ b/UnitsNet.GlobalSetup.DefaultFirst.Tests/UnitsNetSetupDefaultFirstTests.cs @@ -5,7 +5,12 @@ namespace UnitsNet.GlobalSetup.DefaultFirst.Tests; -// This assembly intentionally contains a single test because UnitsNetSetup.Default is process-wide and cannot be reset. +/// +/// Tests configuring the process-wide default setup after first use. +/// +/// +/// This assembly intentionally contains a single test because cannot be reset. +/// public class UnitsNetSetupDefaultFirstTests { [Fact] diff --git a/UnitsNet.GlobalSetup.Tests/UnitsNetSetupGlobalConfigurationTests.cs b/UnitsNet.GlobalSetup.Tests/UnitsNetSetupGlobalConfigurationTests.cs index 846829a5d5..791254ac82 100644 --- a/UnitsNet.GlobalSetup.Tests/UnitsNetSetupGlobalConfigurationTests.cs +++ b/UnitsNet.GlobalSetup.Tests/UnitsNetSetupGlobalConfigurationTests.cs @@ -6,8 +6,13 @@ namespace UnitsNet.GlobalSetup.Tests; -// This assembly intentionally contains a single test because UnitsNetSetup.Default is process-wide and cannot be reset. -// Keep additional global-configuration scenarios isolated in separate test processes. +/// +/// Tests configuring the process-wide default setup before first use. +/// +/// +/// This assembly intentionally contains a single test because cannot be reset. +/// Keep additional global-configuration scenarios isolated in separate test processes. +/// public class UnitsNetSetupGlobalConfigurationTests { [Fact] diff --git a/UnitsNet/CustomCode/UnitsNetSetup.cs b/UnitsNet/CustomCode/UnitsNetSetup.cs index 5b8dcd8a25..9bcb79b7ac 100644 --- a/UnitsNet/CustomCode/UnitsNetSetup.cs +++ b/UnitsNet/CustomCode/UnitsNetSetup.cs @@ -14,7 +14,9 @@ namespace UnitsNet; /// public sealed class UnitsNetSetup { - // Lazy synchronizes value creation; this lock also makes the builder swap and creation checks atomic with it. + /// + /// Synchronizes the default builder swap and creation checks with the value creation already synchronized by . + /// private static readonly object DefaultConfigurationLock = new(); private static DefaultConfigurationBuilder _defaultConfigurationBuilder = new(); private static readonly Lazy DefaultConfiguration = new(BuildDefault); From 633ff0c132fde2085f3c40d9bc73f3199cbbf7e8 Mon Sep 17 00:00:00 2001 From: Andreas Gullberg Larsen Date: Thu, 23 Jul 2026 03:48:30 +0200 Subject: [PATCH 5/5] Collect coverage for global setup tests Run both isolated global setup test projects in the primary test pipeline and cover the ConfigureDefaults null guard. --- Build/build-functions.psm1 | 2 ++ .../UnitsNetSetupGlobalConfigurationTests.cs | 3 +++ 2 files changed, 5 insertions(+) diff --git a/Build/build-functions.psm1 b/Build/build-functions.psm1 index 88cd3b3e7f..fefdcfa372 100644 --- a/Build/build-functions.psm1 +++ b/Build/build-functions.psm1 @@ -40,6 +40,8 @@ function Start-Tests { $projectPaths = @( "UnitsNet.Tests/UnitsNet.Tests.csproj", + "UnitsNet.GlobalSetup.DefaultFirst.Tests/UnitsNet.GlobalSetup.DefaultFirst.Tests.csproj", + "UnitsNet.GlobalSetup.Tests/UnitsNet.GlobalSetup.Tests.csproj", "UnitsNet.NumberExtensions.Tests/UnitsNet.NumberExtensions.Tests.csproj", "UnitsNet.NumberExtensions.CS14.Tests/UnitsNet.NumberExtensions.CS14.Tests.csproj", "UnitsNet.Serialization.JsonNet.Tests/UnitsNet.Serialization.JsonNet.Tests.csproj" diff --git a/UnitsNet.GlobalSetup.Tests/UnitsNetSetupGlobalConfigurationTests.cs b/UnitsNet.GlobalSetup.Tests/UnitsNetSetupGlobalConfigurationTests.cs index 791254ac82..961acb2bdf 100644 --- a/UnitsNet.GlobalSetup.Tests/UnitsNetSetupGlobalConfigurationTests.cs +++ b/UnitsNet.GlobalSetup.Tests/UnitsNetSetupGlobalConfigurationTests.cs @@ -18,6 +18,9 @@ public class UnitsNetSetupGlobalConfigurationTests [Fact] public void ConfigureDefaults_BeforeFirstUse_ConfiguresAndFreezesDefaultSetup() { + Assert.Equal("configuration", + Assert.Throws(() => UnitsNetSetup.ConfigureDefaults(null!)).ParamName); + UnitsNetSetup configured = UnitsNetSetup.ConfigureDefaults(builder => builder.WithQuantities([Mass.Info])); Assert.Same(configured, UnitsNetSetup.Default);