From bdcd36c063dc7006769099c4eacc00cb54755e04 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 4 Jun 2026 21:34:14 +0200 Subject: [PATCH 1/6] refactor: reduce dry4dotnet duplication --- samples/PostHog.Example.Console/Program.cs | 24 +- src/PostHog/Capture/CaptureExtensions.cs | 18 +- .../GroupIdentifyAsyncExtensions.cs | 43 +- src/PostHog/NoOpPostHogClient.cs | 15 +- src/PostHog/PostHogClient.cs | 25 +- .../Fakes/FakeHttpMessageHandlerExtensions.cs | 17 +- tests/UnitTests/Features/FeatureFlagsTests.cs | 217 ++++----- .../UnitTests/Features/LocalEvaluatorTests.cs | 355 +-------------- .../UnitTests/Library/SemanticVersionTests.cs | 410 ++++-------------- 9 files changed, 230 insertions(+), 894 deletions(-) diff --git a/samples/PostHog.Example.Console/Program.cs b/samples/PostHog.Example.Console/Program.cs index 31db61a2..c7895246 100644 --- a/samples/PostHog.Example.Console/Program.cs +++ b/samples/PostHog.Example.Console/Program.cs @@ -149,14 +149,20 @@ static string Prompt(string message) return Console.ReadLine()?.Trim() ?? string.Empty; } -static async Task RunCaptureExamples(PostHogClient posthog) +static string StartExampleSection(string title) { Console.WriteLine("\n" + new string('=', 60)); - Console.WriteLine("CAPTURE EVENTS"); + Console.WriteLine(title); Console.WriteLine(new string('=', 60)); var distinctId = $"user_{Guid.NewGuid():N}"; Console.WriteLine($"\nUsing distinct ID: {distinctId}"); + return distinctId; +} + +static async Task RunCaptureExamples(PostHogClient posthog) +{ + var distinctId = StartExampleSection("CAPTURE EVENTS"); // Simple capture Console.WriteLine("\n📤 Capturing 'page_view' event…"); @@ -191,12 +197,7 @@ static async Task RunCaptureExamples(PostHogClient posthog) static async Task RunIdentifyExamples(PostHogClient posthog) { - Console.WriteLine("\n" + new string('=', 60)); - Console.WriteLine("IDENTIFY USERS"); - Console.WriteLine(new string('=', 60)); - - var distinctId = $"user_{Guid.NewGuid():N}"; - Console.WriteLine($"\nUsing distinct ID: {distinctId}"); + var distinctId = StartExampleSection("IDENTIFY USERS"); // Identify with properties Console.WriteLine("\n👤 Identifying user with properties…"); @@ -232,12 +233,7 @@ await posthog.IdentifyAsync( static async Task RunFeatureFlagExamples(PostHogClient posthog) { - Console.WriteLine("\n" + new string('=', 60)); - Console.WriteLine("FEATURE FLAGS"); - Console.WriteLine(new string('=', 60)); - - var distinctId = $"user_{Guid.NewGuid():N}"; - Console.WriteLine($"\nUsing distinct ID: {distinctId}"); + var distinctId = StartExampleSection("FEATURE FLAGS"); // Check a simple boolean flag Console.WriteLine("\n🚩 Checking feature flag 'new-dashboard'…"); diff --git a/src/PostHog/Capture/CaptureExtensions.cs b/src/PostHog/Capture/CaptureExtensions.cs index 1533f237..f551e5e0 100644 --- a/src/PostHog/Capture/CaptureExtensions.cs +++ b/src/PostHog/Capture/CaptureExtensions.cs @@ -331,15 +331,22 @@ public static bool CapturePageView( string pagePath, Dictionary? properties, bool sendFeatureFlags) + { + if (!sendFeatureFlags) + { + return client.CapturePageView(distinctId, pagePath, properties); + } + #pragma warning disable CS0618 - => NotNull(client).CaptureSpecialEvent( + return NotNull(client).CaptureSpecialEvent( distinctId, eventName: "$pageview", eventPropertyName: "$current_url", eventPropertyValue: pagePath, properties, - sendFeatureFlags); + sendFeatureFlags: true); #pragma warning restore CS0618 + } /// /// Captures a Page View ($pageview) event. @@ -516,12 +523,15 @@ public static bool CaptureSurveyDismissed( string distinctId, string surveyId, Dictionary? properties) - => NotNull(client).CaptureSpecialEvent( + { + const string eventName = "survey dismissed"; + return NotNull(client).CaptureSpecialEvent( distinctId, - eventName: "survey dismissed", + eventName, eventPropertyName: "$survey_id", eventPropertyValue: surveyId, properties); + } static bool CaptureSpecialEvent( this IPostHogClient client, diff --git a/src/PostHog/Extensions/GroupIdentifyAsyncExtensions.cs b/src/PostHog/Extensions/GroupIdentifyAsyncExtensions.cs index 49af674a..8549a0a8 100644 --- a/src/PostHog/Extensions/GroupIdentifyAsyncExtensions.cs +++ b/src/PostHog/Extensions/GroupIdentifyAsyncExtensions.cs @@ -25,11 +25,7 @@ public static async Task GroupIdentifyAsync( StringOrValue key, string name, Dictionary? properties) - { - properties ??= new Dictionary(); - properties["name"] = name; - return await NotNull(client).GroupIdentifyAsync(type, key, properties, CancellationToken.None); - } + => await client.GroupIdentifyAsync(type, key, name, properties, CancellationToken.None); /// /// Sets a groups properties, which allows asking questions like "Who are the most active companies" @@ -49,11 +45,7 @@ public static async Task GroupIdentifyAsync( StringOrValue key, string name, Dictionary? properties) - { - properties ??= new Dictionary(); - properties["name"] = name; - return await NotNull(client).GroupIdentifyAsync(distinctId, type, key, properties, CancellationToken.None); - } + => await client.GroupIdentifyAsync(distinctId, type, key, name, properties, CancellationToken.None); /// /// Sets a groups properties, which allows asking questions like "Who are the most active companies" @@ -73,11 +65,7 @@ public static async Task GroupIdentifyAsync( string name, Dictionary? properties, CancellationToken cancellationToken) - { - properties ??= new Dictionary(); - properties["name"] = name; - return await NotNull(client).GroupIdentifyAsync(type, key, properties, cancellationToken); - } + => await GroupIdentifyWithNameAsync(NotNull(client), distinctId: null, type, key, name, properties, cancellationToken); /// /// Sets a groups properties, which allows asking questions like "Who are the most active companies" @@ -99,11 +87,7 @@ public static async Task GroupIdentifyAsync( string name, Dictionary? properties, CancellationToken cancellationToken) - { - properties ??= new Dictionary(); - properties["name"] = name; - return await NotNull(client).GroupIdentifyAsync(distinctId, type, key, properties, cancellationToken); - } + => await GroupIdentifyWithNameAsync(NotNull(client), distinctId, type, key, name, properties, cancellationToken); /// /// Sets a groups properties, which allows asking questions like "Who are the most active companies" @@ -147,4 +131,21 @@ public static async Task GroupIdentifyAsync( key, name, properties: new Dictionary()); -} \ No newline at end of file + + static async Task GroupIdentifyWithNameAsync( + IPostHogClient client, + string? distinctId, + string type, + StringOrValue key, + string name, + Dictionary? properties, + CancellationToken cancellationToken) + { + properties ??= new Dictionary(); + properties["name"] = name; + + return distinctId is null + ? await client.GroupIdentifyAsync(type, key, properties, cancellationToken) + : await client.GroupIdentifyAsync(distinctId, type, key, properties, cancellationToken); + } +} diff --git a/src/PostHog/NoOpPostHogClient.cs b/src/PostHog/NoOpPostHogClient.cs index 5f164a4c..11db0414 100644 --- a/src/PostHog/NoOpPostHogClient.cs +++ b/src/PostHog/NoOpPostHogClient.cs @@ -9,6 +9,7 @@ namespace PostHog.Sdk; internal sealed class NoOpPostHogClient : IPostHogClient, IFeatureFlagEvaluationsHost { static readonly IReadOnlyDictionary EmptyFeatureFlags = new Dictionary(); + static readonly Task NoOpApiResultTask = Task.FromResult(new ApiResult(0)); NoOpPostHogClient() { @@ -17,21 +18,21 @@ internal sealed class NoOpPostHogClient : IPostHogClient, IFeatureFlagEvaluation internal static NoOpPostHogClient Instance { get; } = new(); public Task AliasAsync(string previousId, string newId, CancellationToken cancellationToken) - => Task.FromResult(new ApiResult(0)); + => NoOpApiResultTask; public Task IdentifyAsync( string distinctId, Dictionary? personPropertiesToSet, Dictionary? personPropertiesToSetOnce, CancellationToken cancellationToken) - => Task.FromResult(new ApiResult(0)); + => NoOpApiResultTask; public Task GroupIdentifyAsync( string type, StringOrValue key, Dictionary? properties, CancellationToken cancellationToken) - => Task.FromResult(new ApiResult(0)); + => GroupIdentifyAsync(string.Empty, type, key, properties, cancellationToken); public Task GroupIdentifyAsync( string distinctId, @@ -39,7 +40,7 @@ public Task GroupIdentifyAsync( StringOrValue key, Dictionary? properties, CancellationToken cancellationToken) - => Task.FromResult(new ApiResult(0)); + => NoOpApiResultTask; public bool Capture( string distinctId, @@ -48,7 +49,7 @@ public bool Capture( GroupCollection? groups, bool sendFeatureFlags, DateTimeOffset? timestamp = null) - => false; + => Capture(distinctId, eventName, properties, groups, flags: null, timestamp); public bool Capture( string distinctId, @@ -66,7 +67,7 @@ public bool CaptureException( GroupCollection? groups, bool sendFeatureFlags, DateTimeOffset? timestamp = null) - => false; + => CaptureException(exception, distinctId, properties, groups, flags: null, timestamp); public bool CaptureException( Exception exception, @@ -75,7 +76,7 @@ public bool CaptureException( GroupCollection? groups, FeatureFlagEvaluations? flags, DateTimeOffset? timestamp = null) - => false; + => Capture(distinctId, exception?.GetType().FullName ?? string.Empty, properties, groups, flags, timestamp); public Task IsFeatureEnabledAsync( string featureKey, diff --git a/src/PostHog/PostHogClient.cs b/src/PostHog/PostHogClient.cs index ad9475e4..3a9c1572 100644 --- a/src/PostHog/PostHogClient.cs +++ b/src/PostHog/PostHogClient.cs @@ -216,22 +216,7 @@ public async Task GroupIdentifyAsync( StringOrValue key, Dictionary? properties, CancellationToken cancellationToken) - { - if (CheckDisabledAndLog(nameof(GroupIdentifyAsync))) - { - return NoOpApiResult; - } - - try - { - return await _apiClient.GroupIdentifyAsync(type, key, properties, cancellationToken); - } - catch (Exception e) when (e is not ArgumentException and not NullReferenceException and not OperationCanceledException) - { - _logger.LogErrorApiCallFailed(e, nameof(GroupIdentifyAsync)); - return NoOpApiResult; - } - } + => await GroupIdentifyCoreAsync(type, key, properties, distinctId: null, cancellationToken); /// public async Task GroupIdentifyAsync( @@ -240,6 +225,14 @@ public async Task GroupIdentifyAsync( StringOrValue key, Dictionary? properties, CancellationToken cancellationToken) + => await GroupIdentifyCoreAsync(type, key, properties, distinctId, cancellationToken); + + async Task GroupIdentifyCoreAsync( + string type, + StringOrValue key, + Dictionary? properties, + string? distinctId, + CancellationToken cancellationToken) { if (CheckDisabledAndLog(nameof(GroupIdentifyAsync))) { diff --git a/tests/TestLibrary/Fakes/FakeHttpMessageHandlerExtensions.cs b/tests/TestLibrary/Fakes/FakeHttpMessageHandlerExtensions.cs index 66b16cef..37686aab 100644 --- a/tests/TestLibrary/Fakes/FakeHttpMessageHandlerExtensions.cs +++ b/tests/TestLibrary/Fakes/FakeHttpMessageHandlerExtensions.cs @@ -12,14 +12,16 @@ internal static class FakeHttpMessageHandlerExtensions static readonly Uri FlagsUrl = new("https://us.i.posthog.com/flags/?v=2"); public static FakeHttpMessageHandler.RequestHandler AddCaptureResponse(this FakeHttpMessageHandler handler) => - handler.AddResponse( - new Uri("https://us.i.posthog.com/capture"), - HttpMethod.Post, - responseBody: new { status = 1 }); + handler.AddIngestionResponse("capture"); public static FakeHttpMessageHandler.RequestHandler AddBatchResponse(this FakeHttpMessageHandler handler) => + handler.AddIngestionResponse("batch"); + + static FakeHttpMessageHandler.RequestHandler AddIngestionResponse( + this FakeHttpMessageHandler handler, + string endpoint) => handler.AddResponse( - new Uri("https://us.i.posthog.com/batch"), + new Uri($"https://us.i.posthog.com/{endpoint}"), HttpMethod.Post, responseBody: new { status = 1 }); @@ -156,10 +158,7 @@ public static FakeHttpMessageHandler.RequestHandler AddDecryptedPayloadResponse( this FakeHttpMessageHandler handler, string key, string responseBody) => - handler.AddResponse( - new Uri($"https://us.i.posthog.com/api/projects/@current/feature_flags/{key}/remote_config?token=fake-project-token"), - HttpMethod.Get, - responseBody: responseBody); + handler.AddRemoteConfigResponse(key, responseBody); static T Deserialize(string json) => JsonSerializer.Deserialize(json, JsonSerializerHelper.Options) ?? throw new ArgumentException("Json is invalid and deserializes to null", nameof(json)); diff --git a/tests/UnitTests/Features/FeatureFlagsTests.cs b/tests/UnitTests/Features/FeatureFlagsTests.cs index 5ce39774..5fa6a62e 100644 --- a/tests/UnitTests/Features/FeatureFlagsTests.cs +++ b/tests/UnitTests/Features/FeatureFlagsTests.cs @@ -3912,172 +3912,121 @@ public class FeatureFlagErrorTracking { [Fact] public async Task IncludesFlagMissingErrorWhenFlagNotInResponse() - { - var container = new TestContainer(); - container.FakeHttpMessageHandler.AddFlagsResponse("""{"featureFlags": {}}"""); - var captureRequestHandler = container.FakeHttpMessageHandler.AddBatchResponse(); - var client = container.Activate(); - - var result = await client.GetFeatureFlagAsync("missing-flag", "distinct-id"); - - Assert.False(result); - await client.FlushAsync(); - var received = captureRequestHandler.GetReceivedRequestBody(indented: true); - Assert.Contains("\"$feature_flag_error\": \"flag_missing\"", received, StringComparison.Ordinal); - } + => await AssertCapturedFeatureFlagErrorAsync( + handler => handler.AddFlagsResponse("""{"featureFlags": {}}"""), + featureKey: "missing-flag", + expectedResult: false, + expectedError: "flag_missing"); [Fact] public async Task IncludesErrorsWhileComputingFlagsErrorWhenErrorsWhileComputingFlagsIsTrue() - { - var container = new TestContainer(); - container.FakeHttpMessageHandler.AddFlagsResponse( - """ - { - "featureFlags": {"some-flag": true}, - "errorsWhileComputingFlags": true - } - """ - ); - var captureRequestHandler = container.FakeHttpMessageHandler.AddBatchResponse(); - var client = container.Activate(); - - var result = await client.GetFeatureFlagAsync("some-flag", "distinct-id"); - - Assert.True(result); - await client.FlushAsync(); - var received = captureRequestHandler.GetReceivedRequestBody(indented: true); - Assert.Contains("\"$feature_flag_error\": \"errors_while_computing_flags\"", received, StringComparison.Ordinal); - } + => await AssertCapturedFeatureFlagErrorAsync( + handler => handler.AddFlagsResponse( + """ + { + "featureFlags": {"some-flag": true}, + "errorsWhileComputingFlags": true + } + """), + featureKey: "some-flag", + expectedResult: true, + expectedError: "errors_while_computing_flags"); [Fact] public async Task IncludesQuotaLimitedErrorWhenQuotaLimitedContainsFeatureFlags() - { - var container = new TestContainer(); - container.FakeHttpMessageHandler.AddFlagsResponse( - """ - { - "featureFlags": {}, - "quotaLimited": ["feature_flags"] - } - """ - ); - var captureRequestHandler = container.FakeHttpMessageHandler.AddBatchResponse(); - var client = container.Activate(); - - var result = await client.GetFeatureFlagAsync("some-flag", "distinct-id"); - - Assert.False(result); - await client.FlushAsync(); - var received = captureRequestHandler.GetReceivedRequestBody(indented: true); - Assert.Contains("\"$feature_flag_error\": \"quota_limited,flag_missing\"", received, StringComparison.Ordinal); - } + => await AssertCapturedFeatureFlagErrorAsync( + handler => handler.AddFlagsResponse( + """ + { + "featureFlags": {}, + "quotaLimited": ["feature_flags"] + } + """), + featureKey: "some-flag", + expectedResult: false, + expectedError: "quota_limited,flag_missing"); [Fact] public async Task IncludesUnknownErrorWhenUnexpectedExceptionOccurs() - { - var container = new TestContainer(); - container.FakeHttpMessageHandler.AddFlagsResponseException(new InvalidOperationException("Unexpected error")); - var captureRequestHandler = container.FakeHttpMessageHandler.AddBatchResponse(); - var client = container.Activate(); - - var result = await client.GetFeatureFlagAsync("some-flag", "distinct-id"); - - Assert.False(result); - await client.FlushAsync(); - var received = captureRequestHandler.GetReceivedRequestBody(indented: true); - Assert.Contains("\"$feature_flag_error\": \"unknown_error\"", received, StringComparison.Ordinal); - } + => await AssertCapturedFeatureFlagErrorAsync( + handler => handler.AddFlagsResponseException(new InvalidOperationException("Unexpected error")), + featureKey: "some-flag", + expectedResult: false, + expectedError: "unknown_error"); [Fact] public async Task JoinsMultipleErrorsWithCommas() - { - var container = new TestContainer(); - container.FakeHttpMessageHandler.AddFlagsResponse( - """ - { - "featureFlags": {}, - "errorsWhileComputingFlags": true - } - """ - ); - var captureRequestHandler = container.FakeHttpMessageHandler.AddBatchResponse(); - var client = container.Activate(); - - var result = await client.GetFeatureFlagAsync("missing-flag", "distinct-id"); - - Assert.False(result); - await client.FlushAsync(); - var received = captureRequestHandler.GetReceivedRequestBody(indented: true); - Assert.Contains("\"$feature_flag_error\": \"errors_while_computing_flags,flag_missing\"", received, StringComparison.Ordinal); - } + => await AssertCapturedFeatureFlagErrorAsync( + handler => handler.AddFlagsResponse( + """ + { + "featureFlags": {}, + "errorsWhileComputingFlags": true + } + """), + featureKey: "missing-flag", + expectedResult: false, + expectedError: "errors_while_computing_flags,flag_missing"); [Fact] public async Task DoesNotIncludeErrorPropertyWhenNoErrors() - { - var container = new TestContainer(); - container.FakeHttpMessageHandler.AddFlagsResponse( - """ - {"featureFlags": {"some-flag": true}} - """ - ); - var captureRequestHandler = container.FakeHttpMessageHandler.AddBatchResponse(); - var client = container.Activate(); - - var result = await client.GetFeatureFlagAsync("some-flag", "distinct-id"); - - Assert.True(result); - await client.FlushAsync(); - var received = captureRequestHandler.GetReceivedRequestBody(indented: true); - Assert.DoesNotContain("$feature_flag_error", received, StringComparison.Ordinal); - } + => await AssertCapturedFeatureFlagErrorAsync( + handler => handler.AddFlagsResponse("""{"featureFlags": {"some-flag": true}}"""), + featureKey: "some-flag", + expectedResult: true, + expectedError: null); [Fact] public async Task IncludesTimeoutErrorWhenRequestTimesOut() - { - var container = new TestContainer(); - container.FakeHttpMessageHandler.AddFlagsResponseException(new TaskCanceledException("Request timed out")); - var captureRequestHandler = container.FakeHttpMessageHandler.AddBatchResponse(); - var client = container.Activate(); - - var result = await client.GetFeatureFlagAsync("some-flag", "distinct-id"); - - Assert.False(result); - await client.FlushAsync(); - var received = captureRequestHandler.GetReceivedRequestBody(indented: true); - Assert.Contains("\"$feature_flag_error\": \"timeout\"", received, StringComparison.Ordinal); - } + => await AssertCapturedFeatureFlagErrorAsync( + handler => handler.AddFlagsResponseException(new TaskCanceledException("Request timed out")), + featureKey: "some-flag", + expectedResult: false, + expectedError: "timeout"); [Fact] public async Task IncludesConnectionErrorWhenNetworkFails() - { - var container = new TestContainer(); - container.FakeHttpMessageHandler.AddFlagsResponseException(new HttpRequestException("Network error")); - var captureRequestHandler = container.FakeHttpMessageHandler.AddBatchResponse(); - var client = container.Activate(); - - var result = await client.GetFeatureFlagAsync("some-flag", "distinct-id"); - - Assert.False(result); - await client.FlushAsync(); - var received = captureRequestHandler.GetReceivedRequestBody(indented: true); - Assert.Contains("\"$feature_flag_error\": \"connection_error\"", received, StringComparison.Ordinal); - } + => await AssertCapturedFeatureFlagErrorAsync( + handler => handler.AddFlagsResponseException(new HttpRequestException("Network error")), + featureKey: "some-flag", + expectedResult: false, + expectedError: "connection_error"); [Fact] public async Task IncludesApiErrorWithStatusCodeWhenApiFails() + => await AssertCapturedFeatureFlagErrorAsync( + handler => handler.AddFlagsResponseException( + new ApiException(null, System.Net.HttpStatusCode.InternalServerError, null)), + featureKey: "some-flag", + expectedResult: false, + expectedError: "api_error_500"); + + static async Task AssertCapturedFeatureFlagErrorAsync( + Action arrangeFlagsResponse, + string featureKey, + bool expectedResult, + string? expectedError) { var container = new TestContainer(); - container.FakeHttpMessageHandler.AddFlagsResponseException( - new ApiException(null, System.Net.HttpStatusCode.InternalServerError, null)); + arrangeFlagsResponse(container.FakeHttpMessageHandler); var captureRequestHandler = container.FakeHttpMessageHandler.AddBatchResponse(); var client = container.Activate(); - var result = await client.GetFeatureFlagAsync("some-flag", "distinct-id"); + var result = await client.GetFeatureFlagAsync(featureKey, "distinct-id"); - Assert.False(result); + Assert.NotNull(result); + Assert.Equal(expectedResult, result.IsEnabled); await client.FlushAsync(); var received = captureRequestHandler.GetReceivedRequestBody(indented: true); - Assert.Contains("\"$feature_flag_error\": \"api_error_500\"", received, StringComparison.Ordinal); + + if (expectedError is null) + { + Assert.DoesNotContain("$feature_flag_error", received, StringComparison.Ordinal); + } + else + { + Assert.Contains($"\"$feature_flag_error\": \"{expectedError}\"", received, StringComparison.Ordinal); + } } [Fact] diff --git a/tests/UnitTests/Features/LocalEvaluatorTests.cs b/tests/UnitTests/Features/LocalEvaluatorTests.cs index eaf5bf5d..a821505f 100644 --- a/tests/UnitTests/Features/LocalEvaluatorTests.cs +++ b/tests/UnitTests/Features/LocalEvaluatorTests.cs @@ -1874,204 +1874,30 @@ static LocalEvaluationApiResult CreateFlags(string key, IReadOnlyList - { - ["app_version"] = overrideValue - }; - var localEvaluator = new LocalEvaluator(flags); - - var result = localEvaluator.EvaluateFeatureFlag( - key: "version", - distinctId: "distinct-id", - personProperties: properties); - - Assert.Equal(expected, result); - } - - [Theory] // Not equal tests [InlineData("1.2.3", ComparisonOperator.SemverNotEquals, "1.2.3", false)] [InlineData("1.2.3", ComparisonOperator.SemverNotEquals, "1.2.4", true)] [InlineData("1.2.3", ComparisonOperator.SemverNotEquals, "1.2.2", true)] [InlineData("2.0.0", ComparisonOperator.SemverNotEquals, "1.0.0", true)] - public void HandlesSemverNotEqualsOperator(string overrideValue, ComparisonOperator comparison, string filterValue, bool expected) - { - var flags = CreateFlags( - key: "version", - properties: [ - new PropertyFilter - { - Type = FilterType.Person, - Key = "app_version", - Value = new PropertyFilterValue(filterValue), - Operator = comparison - } - ] - ); - var properties = new Dictionary - { - ["app_version"] = overrideValue - }; - var localEvaluator = new LocalEvaluator(flags); - - var result = localEvaluator.EvaluateFeatureFlag( - key: "version", - distinctId: "distinct-id", - personProperties: properties); - - Assert.Equal(expected, result); - } - - [Theory] // Greater than tests [InlineData("1.2.4", ComparisonOperator.SemverGreaterThan, "1.2.3", true)] [InlineData("1.2.3", ComparisonOperator.SemverGreaterThan, "1.2.3", false)] [InlineData("1.2.2", ComparisonOperator.SemverGreaterThan, "1.2.3", false)] [InlineData("2.0.0", ComparisonOperator.SemverGreaterThan, "1.9.9", true)] [InlineData("1.3.0", ComparisonOperator.SemverGreaterThan, "1.2.99", true)] - public void HandlesSemverGreaterThanOperator(string overrideValue, ComparisonOperator comparison, string filterValue, bool expected) - { - var flags = CreateFlags( - key: "version", - properties: [ - new PropertyFilter - { - Type = FilterType.Person, - Key = "app_version", - Value = new PropertyFilterValue(filterValue), - Operator = comparison - } - ] - ); - var properties = new Dictionary - { - ["app_version"] = overrideValue - }; - var localEvaluator = new LocalEvaluator(flags); - - var result = localEvaluator.EvaluateFeatureFlag( - key: "version", - distinctId: "distinct-id", - personProperties: properties); - - Assert.Equal(expected, result); - } - - [Theory] // Greater than or equal tests [InlineData("1.2.4", ComparisonOperator.SemverGreaterThanOrEquals, "1.2.3", true)] [InlineData("1.2.3", ComparisonOperator.SemverGreaterThanOrEquals, "1.2.3", true)] [InlineData("1.2.2", ComparisonOperator.SemverGreaterThanOrEquals, "1.2.3", false)] - public void HandlesSemverGreaterThanOrEqualsOperator(string overrideValue, ComparisonOperator comparison, string filterValue, bool expected) - { - var flags = CreateFlags( - key: "version", - properties: [ - new PropertyFilter - { - Type = FilterType.Person, - Key = "app_version", - Value = new PropertyFilterValue(filterValue), - Operator = comparison - } - ] - ); - var properties = new Dictionary - { - ["app_version"] = overrideValue - }; - var localEvaluator = new LocalEvaluator(flags); - - var result = localEvaluator.EvaluateFeatureFlag( - key: "version", - distinctId: "distinct-id", - personProperties: properties); - - Assert.Equal(expected, result); - } - - [Theory] // Less than tests [InlineData("1.2.2", ComparisonOperator.SemverLessThan, "1.2.3", true)] [InlineData("1.2.3", ComparisonOperator.SemverLessThan, "1.2.3", false)] [InlineData("1.2.4", ComparisonOperator.SemverLessThan, "1.2.3", false)] [InlineData("1.9.9", ComparisonOperator.SemverLessThan, "2.0.0", true)] - public void HandlesSemverLessThanOperator(string overrideValue, ComparisonOperator comparison, string filterValue, bool expected) - { - var flags = CreateFlags( - key: "version", - properties: [ - new PropertyFilter - { - Type = FilterType.Person, - Key = "app_version", - Value = new PropertyFilterValue(filterValue), - Operator = comparison - } - ] - ); - var properties = new Dictionary - { - ["app_version"] = overrideValue - }; - var localEvaluator = new LocalEvaluator(flags); - - var result = localEvaluator.EvaluateFeatureFlag( - key: "version", - distinctId: "distinct-id", - personProperties: properties); - - Assert.Equal(expected, result); - } - - [Theory] // Less than or equal tests [InlineData("1.2.2", ComparisonOperator.SemverLessThanOrEquals, "1.2.3", true)] [InlineData("1.2.3", ComparisonOperator.SemverLessThanOrEquals, "1.2.3", true)] [InlineData("1.2.4", ComparisonOperator.SemverLessThanOrEquals, "1.2.3", false)] - public void HandlesSemverLessThanOrEqualsOperator(string overrideValue, ComparisonOperator comparison, string filterValue, bool expected) - { - var flags = CreateFlags( - key: "version", - properties: [ - new PropertyFilter - { - Type = FilterType.Person, - Key = "app_version", - Value = new PropertyFilterValue(filterValue), - Operator = comparison - } - ] - ); - var properties = new Dictionary - { - ["app_version"] = overrideValue - }; - var localEvaluator = new LocalEvaluator(flags); - - var result = localEvaluator.EvaluateFeatureFlag( - key: "version", - distinctId: "distinct-id", - personProperties: properties); - - Assert.Equal(expected, result); - } - - [Theory] // Tilde operator tests: ~X.Y.Z means >=X.Y.Z and - { - ["app_version"] = overrideValue - }; - var localEvaluator = new LocalEvaluator(flags); - - var result = localEvaluator.EvaluateFeatureFlag( - key: "version", - distinctId: "distinct-id", - personProperties: properties); - - Assert.Equal(expected, result); - } - - [Theory] // Caret operator tests for major > 0: ^X.Y.Z means >=X.Y.Z and - { - ["app_version"] = overrideValue - }; - var localEvaluator = new LocalEvaluator(flags); - - var result = localEvaluator.EvaluateFeatureFlag( - key: "version", - distinctId: "distinct-id", - personProperties: properties); - - Assert.Equal(expected, result); - } - - [Theory] // Caret operator tests for major = 0, minor > 0: ^0.Y.Z means >=0.Y.Z and <0.Y+1.0 [InlineData("0.2.3", ComparisonOperator.SemverCaret, "0.2.3", true)] // At lower bound [InlineData("0.2.4", ComparisonOperator.SemverCaret, "0.2.3", true)] // Within range @@ -2151,148 +1919,29 @@ public void HandlesSemverCaretOperatorWithMajorGreaterThanZero(string overrideVa [InlineData("0.3.0", ComparisonOperator.SemverCaret, "0.2.3", false)] // At upper bound (exclusive) [InlineData("0.2.2", ComparisonOperator.SemverCaret, "0.2.3", false)] // Below range [InlineData("1.0.0", ComparisonOperator.SemverCaret, "0.2.3", false)] // Above range - public void HandlesSemverCaretOperatorWithMajorZeroMinorGreaterThanZero(string overrideValue, ComparisonOperator comparison, string filterValue, bool expected) - { - var flags = CreateFlags( - key: "version", - properties: [ - new PropertyFilter - { - Type = FilterType.Person, - Key = "app_version", - Value = new PropertyFilterValue(filterValue), - Operator = comparison - } - ] - ); - var properties = new Dictionary - { - ["app_version"] = overrideValue - }; - var localEvaluator = new LocalEvaluator(flags); - - var result = localEvaluator.EvaluateFeatureFlag( - key: "version", - distinctId: "distinct-id", - personProperties: properties); - - Assert.Equal(expected, result); - } - - [Theory] // Caret operator tests for major = 0, minor = 0: ^0.0.Z means >=0.0.Z and <0.0.Z+1 [InlineData("0.0.3", ComparisonOperator.SemverCaret, "0.0.3", true)] // At lower bound [InlineData("0.0.4", ComparisonOperator.SemverCaret, "0.0.3", false)] // At upper bound (exclusive) [InlineData("0.0.2", ComparisonOperator.SemverCaret, "0.0.3", false)] // Below range [InlineData("0.1.0", ComparisonOperator.SemverCaret, "0.0.3", false)] // Above range - public void HandlesSemverCaretOperatorWithMajorAndMinorZero(string overrideValue, ComparisonOperator comparison, string filterValue, bool expected) - { - var flags = CreateFlags( - key: "version", - properties: [ - new PropertyFilter - { - Type = FilterType.Person, - Key = "app_version", - Value = new PropertyFilterValue(filterValue), - Operator = comparison - } - ] - ); - var properties = new Dictionary - { - ["app_version"] = overrideValue - }; - var localEvaluator = new LocalEvaluator(flags); - - var result = localEvaluator.EvaluateFeatureFlag( - key: "version", - distinctId: "distinct-id", - personProperties: properties); - - Assert.Equal(expected, result); - } - - [Theory] - // Wildcard operator tests: "X.*" means >=X.0.0 and - { - ["app_version"] = overrideValue - }; - var localEvaluator = new LocalEvaluator(flags); - - var result = localEvaluator.EvaluateFeatureFlag( - key: "version", - distinctId: "distinct-id", - personProperties: properties); - - Assert.Equal(expected, result); - } - - [Theory] - // Wildcard operator tests: "X.Y.*" means >=X.Y.0 and - { - ["app_version"] = overrideValue - }; - var localEvaluator = new LocalEvaluator(flags); - - var result = localEvaluator.EvaluateFeatureFlag( - key: "version", - distinctId: "distinct-id", - personProperties: properties); - - Assert.Equal(expected, result); - } - - [Theory] // Special version parsing tests - [InlineData("v1.2.3", ComparisonOperator.SemverEquals, "1.2.3", true)] // v-prefix [InlineData("1.2.3", ComparisonOperator.SemverEquals, "v1.2.3", true)] // v-prefix in filter - [InlineData("1.2.3-alpha", ComparisonOperator.SemverEquals, "1.2.3", true)] // Pre-release stripped [InlineData("1.2.3+build", ComparisonOperator.SemverEquals, "1.2.3", true)] // Build metadata stripped [InlineData(" 1.2.3 ", ComparisonOperator.SemverEquals, "1.2.3", true)] // Whitespace stripped [InlineData("1.2", ComparisonOperator.SemverEquals, "1.2.0", true)] // Partial version [InlineData("1", ComparisonOperator.SemverEquals, "1.0.0", true)] // Partial version [InlineData("1.2.3.4", ComparisonOperator.SemverEquals, "1.2.3", true)] // Extra parts ignored - public void HandlesSpecialVersionFormats(string overrideValue, ComparisonOperator comparison, string filterValue, bool expected) + public void EvaluatesSemverOperators(string overrideValue, ComparisonOperator comparison, string filterValue, bool expected) { var flags = CreateFlags( key: "version", diff --git a/tests/UnitTests/Library/SemanticVersionTests.cs b/tests/UnitTests/Library/SemanticVersionTests.cs index 9a7e5bf4..97ff9b46 100644 --- a/tests/UnitTests/Library/SemanticVersionTests.cs +++ b/tests/UnitTests/Library/SemanticVersionTests.cs @@ -10,50 +10,14 @@ public class TheTryParseMethod [InlineData("0.0.0", 0, 0, 0)] [InlineData("10.20.30", 10, 20, 30)] [InlineData("999.999.999", 999, 999, 999)] - public void ParsesValidVersions(string input, int expectedMajor, int expectedMinor, int expectedPatch) - { - var result = SemanticVersion.TryParse(input, out var version); - - Assert.True(result); - Assert.NotNull(version); - Assert.Equal(expectedMajor, version.Value.Major); - Assert.Equal(expectedMinor, version.Value.Minor); - Assert.Equal(expectedPatch, version.Value.Patch); - } - - [Theory] // v-prefix handling [InlineData("v1.2.3", 1, 2, 3)] [InlineData("V1.2.3", 1, 2, 3)] [InlineData("v0.0.1", 0, 0, 1)] - public void StripsVPrefix(string input, int expectedMajor, int expectedMinor, int expectedPatch) - { - var result = SemanticVersion.TryParse(input, out var version); - - Assert.True(result); - Assert.NotNull(version); - Assert.Equal(expectedMajor, version.Value.Major); - Assert.Equal(expectedMinor, version.Value.Minor); - Assert.Equal(expectedPatch, version.Value.Patch); - } - - [Theory] // Whitespace handling [InlineData(" 1.2.3 ", 1, 2, 3)] - [InlineData("\t1.2.3\t", 1, 2, 3)] + [InlineData(" 1.2.3 ", 1, 2, 3)] [InlineData(" v1.2.3 ", 1, 2, 3)] - public void StripsWhitespace(string input, int expectedMajor, int expectedMinor, int expectedPatch) - { - var result = SemanticVersion.TryParse(input, out var version); - - Assert.True(result); - Assert.NotNull(version); - Assert.Equal(expectedMajor, version.Value.Major); - Assert.Equal(expectedMinor, version.Value.Minor); - Assert.Equal(expectedPatch, version.Value.Patch); - } - - [Theory] // Pre-release and build metadata stripping [InlineData("1.2.3-alpha", 1, 2, 3)] [InlineData("1.2.3-alpha.1", 1, 2, 3)] @@ -62,57 +26,21 @@ public void StripsWhitespace(string input, int expectedMajor, int expectedMinor, [InlineData("1.2.3-alpha+build", 1, 2, 3)] [InlineData("1.2.3-beta.2+build.456", 1, 2, 3)] [InlineData("v1.2.3-rc1", 1, 2, 3)] - public void StripsPreReleaseAndBuildMetadata(string input, int expectedMajor, int expectedMinor, int expectedPatch) - { - var result = SemanticVersion.TryParse(input, out var version); - - Assert.True(result); - Assert.NotNull(version); - Assert.Equal(expectedMajor, version.Value.Major); - Assert.Equal(expectedMinor, version.Value.Minor); - Assert.Equal(expectedPatch, version.Value.Patch); - } - - [Theory] // Partial versions (missing components default to 0) [InlineData("1", 1, 0, 0)] [InlineData("1.2", 1, 2, 0)] [InlineData("v1", 1, 0, 0)] [InlineData("v1.2", 1, 2, 0)] - public void DefaultsMissingComponentsToZero(string input, int expectedMajor, int expectedMinor, int expectedPatch) - { - var result = SemanticVersion.TryParse(input, out var version); - - Assert.True(result); - Assert.NotNull(version); - Assert.Equal(expectedMajor, version.Value.Major); - Assert.Equal(expectedMinor, version.Value.Minor); - Assert.Equal(expectedPatch, version.Value.Patch); - } - - [Theory] // Extra components beyond the third are ignored [InlineData("1.2.3.4", 1, 2, 3)] [InlineData("1.2.3.4.5", 1, 2, 3)] [InlineData("1.2.3.4.5.6", 1, 2, 3)] - public void IgnoresExtraComponents(string input, int expectedMajor, int expectedMinor, int expectedPatch) - { - var result = SemanticVersion.TryParse(input, out var version); - - Assert.True(result); - Assert.NotNull(version); - Assert.Equal(expectedMajor, version.Value.Major); - Assert.Equal(expectedMinor, version.Value.Minor); - Assert.Equal(expectedPatch, version.Value.Patch); - } - - [Theory] // Literal "0" components are valid per semver 2.0.0 [InlineData("0.0.1", 0, 0, 1)] [InlineData("0.1.0", 0, 1, 0)] [InlineData("1.0.0", 1, 0, 0)] [InlineData("1.2.0", 1, 2, 0)] - public void ParsesLiteralZeroComponents(string input, int expectedMajor, int expectedMinor, int expectedPatch) + public void ParsesValidInputs(string input, int expectedMajor, int expectedMinor, int expectedPatch) { var result = SemanticVersion.TryParse(input, out var version); @@ -171,148 +99,50 @@ public class TheCompareToMethod [InlineData("0.0.0", "0.0.0", 0)] [InlineData("v1.2.3", "1.2.3", 0)] [InlineData("1.2.3-alpha", "1.2.3", 0)] // Pre-release stripped, so equal - public void ReturnsZeroForEqualVersions(string left, string right, int expected) - { - Assert.True(SemanticVersion.TryParse(left, out var leftVersion)); - Assert.True(SemanticVersion.TryParse(right, out var rightVersion)); - - Assert.NotNull(leftVersion); - Assert.NotNull(rightVersion); - Assert.Equal(expected, leftVersion.Value.CompareTo(rightVersion.Value)); - } - - [Theory] // Greater than comparisons [InlineData("2.0.0", "1.0.0", 1)] [InlineData("1.2.0", "1.1.0", 1)] [InlineData("1.2.4", "1.2.3", 1)] [InlineData("2.0.0", "1.9.9", 1)] [InlineData("1.0.0", "0.9.9", 1)] - public void ReturnsPositiveWhenLeftIsGreater(string left, string right, int expected) - { - Assert.True(SemanticVersion.TryParse(left, out var leftVersion)); - Assert.True(SemanticVersion.TryParse(right, out var rightVersion)); - - Assert.NotNull(leftVersion); - Assert.NotNull(rightVersion); - Assert.Equal(expected, Math.Sign(leftVersion.Value.CompareTo(rightVersion.Value))); - } - - [Theory] // Less than comparisons [InlineData("1.0.0", "2.0.0", -1)] [InlineData("1.1.0", "1.2.0", -1)] [InlineData("1.2.3", "1.2.4", -1)] [InlineData("1.9.9", "2.0.0", -1)] [InlineData("0.9.9", "1.0.0", -1)] - public void ReturnsNegativeWhenLeftIsLess(string left, string right, int expected) + public void ComparesVersions(string left, string right, int expectedSign) { Assert.True(SemanticVersion.TryParse(left, out var leftVersion)); Assert.True(SemanticVersion.TryParse(right, out var rightVersion)); Assert.NotNull(leftVersion); Assert.NotNull(rightVersion); - Assert.Equal(expected, Math.Sign(leftVersion.Value.CompareTo(rightVersion.Value))); + Assert.Equal(expectedSign, Math.Sign(leftVersion.Value.CompareTo(rightVersion.Value))); } } -public class TheGetTildeBoundsMethod +public class TheVersionRangeBoundsMethods { [Theory] // ~X.Y.Z means >=X.Y.Z and 0 → >=X.Y.Z 0 → >=0.Y.Z <0.Y+1.0 - [InlineData("0.2.3", "0.2.3", "0.3.0")] - [InlineData("0.1.0", "0.1.0", "0.2.0")] - [InlineData("0.5.10", "0.5.10", "0.6.0")] - public void CalculatesCorrectCaretBoundsForMajorZeroMinorGreaterThanZero(string input, string expectedLower, string expectedUpper) - { - Assert.True(SemanticVersion.TryParse(input, out var version)); - Assert.True(SemanticVersion.TryParse(expectedLower, out var expectedLowerVersion)); - Assert.True(SemanticVersion.TryParse(expectedUpper, out var expectedUpperVersion)); - - Assert.NotNull(version); - Assert.NotNull(expectedLowerVersion); - Assert.NotNull(expectedUpperVersion); - - var (lower, upper) = version.Value.GetCaretBounds(); - - Assert.Equal(expectedLowerVersion.Value, lower); - Assert.Equal(expectedUpperVersion.Value, upper); - } - - [Theory] + [InlineData("caret", "0.2.3", "0.2.3", "0.3.0")] + [InlineData("caret", "0.1.0", "0.1.0", "0.2.0")] + [InlineData("caret", "0.5.10", "0.5.10", "0.6.0")] // ^0.0.Z → >=0.0.Z <0.0.Z+1 - [InlineData("0.0.3", "0.0.3", "0.0.4")] - [InlineData("0.0.0", "0.0.0", "0.0.1")] - [InlineData("0.0.10", "0.0.10", "0.0.11")] - public void CalculatesCorrectCaretBoundsForMajorAndMinorZero(string input, string expectedLower, string expectedUpper) + [InlineData("caret", "0.0.3", "0.0.3", "0.0.4")] + [InlineData("caret", "0.0.0", "0.0.0", "0.0.1")] + [InlineData("caret", "0.0.10", "0.0.10", "0.0.11")] + public void CalculatesCorrectBounds(string kind, string input, string expectedLower, string expectedUpper) { Assert.True(SemanticVersion.TryParse(input, out var version)); Assert.True(SemanticVersion.TryParse(expectedLower, out var expectedLowerVersion)); @@ -322,63 +152,42 @@ public void CalculatesCorrectCaretBoundsForMajorAndMinorZero(string input, strin Assert.NotNull(expectedLowerVersion); Assert.NotNull(expectedUpperVersion); - var (lower, upper) = version.Value.GetCaretBounds(); + var (lower, upper) = kind == "tilde" + ? version.Value.GetTildeBounds() + : version.Value.GetCaretBounds(); Assert.Equal(expectedLowerVersion.Value, lower); Assert.Equal(expectedUpperVersion.Value, upper); } [Theory] - // Test range matching for caret with major > 0 - [InlineData("1.2.3", "1.2.3", true)] // At lower bound - [InlineData("1.2.3", "1.2.4", true)] // Within range - [InlineData("1.2.3", "1.9.9", true)] // Within range - [InlineData("1.2.3", "2.0.0", false)] // At upper bound (exclusive) - [InlineData("1.2.3", "1.2.2", false)] // Below range - [InlineData("1.2.3", "3.0.0", false)] // Above range - public void CaretBoundsMatchCorrectlyForMajorGreaterThanZero(string baseVersion, string testVersion, bool expectedInRange) - { - Assert.True(SemanticVersion.TryParse(baseVersion, out var baseVer)); - Assert.True(SemanticVersion.TryParse(testVersion, out var testVer)); - - Assert.NotNull(baseVer); - Assert.NotNull(testVer); - - var (lower, upper) = baseVer.Value.GetCaretBounds(); - var inRange = testVer.Value.IsInRange(lower, upper); - - Assert.Equal(expectedInRange, inRange); - } - - [Theory] - // Test range matching for caret with major = 0, minor > 0 - [InlineData("0.2.3", "0.2.3", true)] // At lower bound - [InlineData("0.2.3", "0.2.4", true)] // Within range - [InlineData("0.2.3", "0.2.99", true)] // Within range - [InlineData("0.2.3", "0.3.0", false)] // At upper bound (exclusive) - [InlineData("0.2.3", "0.2.2", false)] // Below range - [InlineData("0.2.3", "1.0.0", false)] // Above range - public void CaretBoundsMatchCorrectlyForMajorZeroMinorGreaterThanZero(string baseVersion, string testVersion, bool expectedInRange) - { - Assert.True(SemanticVersion.TryParse(baseVersion, out var baseVer)); - Assert.True(SemanticVersion.TryParse(testVersion, out var testVer)); - - Assert.NotNull(baseVer); - Assert.NotNull(testVer); - - var (lower, upper) = baseVer.Value.GetCaretBounds(); - var inRange = testVer.Value.IsInRange(lower, upper); - - Assert.Equal(expectedInRange, inRange); - } - - [Theory] - // Test range matching for caret with major = 0, minor = 0 - [InlineData("0.0.3", "0.0.3", true)] // At lower bound - [InlineData("0.0.3", "0.0.4", false)] // At upper bound (exclusive) - [InlineData("0.0.3", "0.0.2", false)] // Below range - [InlineData("0.0.3", "0.1.0", false)] // Above range - public void CaretBoundsMatchCorrectlyForMajorAndMinorZero(string baseVersion, string testVersion, bool expectedInRange) + // Tilde range matching + [InlineData("tilde", "1.2.3", "1.2.3", true)] + [InlineData("tilde", "1.2.3", "1.2.4", true)] + [InlineData("tilde", "1.2.3", "1.2.99", true)] + [InlineData("tilde", "1.2.3", "1.3.0", false)] + [InlineData("tilde", "1.2.3", "1.2.2", false)] + [InlineData("tilde", "1.2.3", "2.0.0", false)] + // Caret range matching with major > 0 + [InlineData("caret", "1.2.3", "1.2.3", true)] + [InlineData("caret", "1.2.3", "1.2.4", true)] + [InlineData("caret", "1.2.3", "1.9.9", true)] + [InlineData("caret", "1.2.3", "2.0.0", false)] + [InlineData("caret", "1.2.3", "1.2.2", false)] + [InlineData("caret", "1.2.3", "3.0.0", false)] + // Caret range matching with major = 0, minor > 0 + [InlineData("caret", "0.2.3", "0.2.3", true)] + [InlineData("caret", "0.2.3", "0.2.4", true)] + [InlineData("caret", "0.2.3", "0.2.99", true)] + [InlineData("caret", "0.2.3", "0.3.0", false)] + [InlineData("caret", "0.2.3", "0.2.2", false)] + [InlineData("caret", "0.2.3", "1.0.0", false)] + // Caret range matching with major = 0, minor = 0 + [InlineData("caret", "0.0.3", "0.0.3", true)] + [InlineData("caret", "0.0.3", "0.0.4", false)] + [InlineData("caret", "0.0.3", "0.0.2", false)] + [InlineData("caret", "0.0.3", "0.1.0", false)] + public void BoundsMatchCorrectly(string kind, string baseVersion, string testVersion, bool expectedInRange) { Assert.True(SemanticVersion.TryParse(baseVersion, out var baseVer)); Assert.True(SemanticVersion.TryParse(testVersion, out var testVer)); @@ -386,7 +195,9 @@ public void CaretBoundsMatchCorrectlyForMajorAndMinorZero(string baseVersion, st Assert.NotNull(baseVer); Assert.NotNull(testVer); - var (lower, upper) = baseVer.Value.GetCaretBounds(); + var (lower, upper) = kind == "tilde" + ? baseVer.Value.GetTildeBounds() + : baseVer.Value.GetCaretBounds(); var inRange = testVer.Value.IsInRange(lower, upper); Assert.Equal(expectedInRange, inRange); @@ -401,66 +212,18 @@ public class TheTryParseWildcardMethod [InlineData("2.*", "2.0.0", "3.0.0")] [InlineData("0.*", "0.0.0", "1.0.0")] [InlineData("v1.*", "1.0.0", "2.0.0")] - public void ParsesXWildcardPattern(string pattern, string expectedLower, string expectedUpper) - { - var result = SemanticVersion.TryParseWildcard(pattern, out var lower, out var upper); - - Assert.True(result); - Assert.NotNull(lower); - Assert.NotNull(upper); - - Assert.True(SemanticVersion.TryParse(expectedLower, out var expectedLowerVersion)); - Assert.True(SemanticVersion.TryParse(expectedUpper, out var expectedUpperVersion)); - - Assert.Equal(expectedLowerVersion!.Value, lower.Value); - Assert.Equal(expectedUpperVersion!.Value, upper.Value); - } - - [Theory] // "X.Y.*" pattern → >=X.Y.0 =X.0.0 =X.Y.0 ", "1.2.4", "1.2.3", true)] + [InlineData(">", "1.2.3", "1.2.3", false)] + [InlineData(">", "1.2.3", "1.2.4", false)] + [InlineData(">=", "1.2.4", "1.2.3", true)] + [InlineData(">=", "1.2.3", "1.2.3", true)] + [InlineData(">=", "1.2.3", "1.2.4", false)] + public void ComparisonOperatorsWork(string operatorName, string left, string right, bool expected) { Assert.True(SemanticVersion.TryParse(left, out var leftVersion)); Assert.True(SemanticVersion.TryParse(right, out var rightVersion)); Assert.NotNull(leftVersion); Assert.NotNull(rightVersion); - Assert.Equal(expected, leftVersion.Value > rightVersion.Value); - } - [Theory] - [InlineData("1.2.4", "1.2.3", true)] - [InlineData("1.2.3", "1.2.3", true)] - [InlineData("1.2.3", "1.2.4", false)] - public void GreaterThanOrEqualOperatorWorks(string left, string right, bool expected) - { - Assert.True(SemanticVersion.TryParse(left, out var leftVersion)); - Assert.True(SemanticVersion.TryParse(right, out var rightVersion)); - - Assert.NotNull(leftVersion); - Assert.NotNull(rightVersion); - Assert.Equal(expected, leftVersion.Value >= rightVersion.Value); + var actual = operatorName switch + { + "<" => leftVersion.Value < rightVersion.Value, + "<=" => leftVersion.Value <= rightVersion.Value, + ">" => leftVersion.Value > rightVersion.Value, + _ => leftVersion.Value >= rightVersion.Value + }; + Assert.Equal(expected, actual); } } From 0c6c389b38be8cd528332c9fa42774818fd37ad6 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 4 Jun 2026 23:39:38 +0200 Subject: [PATCH 2/6] address pr review feedback --- .../UnitTests/Library/SemanticVersionTests.cs | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/tests/UnitTests/Library/SemanticVersionTests.cs b/tests/UnitTests/Library/SemanticVersionTests.cs index 97ff9b46..0e0c69bc 100644 --- a/tests/UnitTests/Library/SemanticVersionTests.cs +++ b/tests/UnitTests/Library/SemanticVersionTests.cs @@ -16,7 +16,7 @@ public class TheTryParseMethod [InlineData("v0.0.1", 0, 0, 1)] // Whitespace handling [InlineData(" 1.2.3 ", 1, 2, 3)] - [InlineData(" 1.2.3 ", 1, 2, 3)] + [InlineData("\t1.2.3\t", 1, 2, 3)] [InlineData(" v1.2.3 ", 1, 2, 3)] // Pre-release and build metadata stripping [InlineData("1.2.3-alpha", 1, 2, 3)] @@ -152,9 +152,7 @@ public void CalculatesCorrectBounds(string kind, string input, string expectedLo Assert.NotNull(expectedLowerVersion); Assert.NotNull(expectedUpperVersion); - var (lower, upper) = kind == "tilde" - ? version.Value.GetTildeBounds() - : version.Value.GetCaretBounds(); + var (lower, upper) = GetBounds(kind, version.Value); Assert.Equal(expectedLowerVersion.Value, lower); Assert.Equal(expectedUpperVersion.Value, upper); @@ -195,13 +193,19 @@ public void BoundsMatchCorrectly(string kind, string baseVersion, string testVer Assert.NotNull(baseVer); Assert.NotNull(testVer); - var (lower, upper) = kind == "tilde" - ? baseVer.Value.GetTildeBounds() - : baseVer.Value.GetCaretBounds(); + var (lower, upper) = GetBounds(kind, baseVer.Value); var inRange = testVer.Value.IsInRange(lower, upper); Assert.Equal(expectedInRange, inRange); } + + static (SemanticVersion Lower, SemanticVersion Upper) GetBounds(string kind, SemanticVersion version) + => kind switch + { + "tilde" => version.GetTildeBounds(), + "caret" => version.GetCaretBounds(), + _ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null) + }; } public class TheTryParseWildcardMethod @@ -321,7 +325,8 @@ public void ComparisonOperatorsWork(string operatorName, string left, string rig "<" => leftVersion.Value < rightVersion.Value, "<=" => leftVersion.Value <= rightVersion.Value, ">" => leftVersion.Value > rightVersion.Value, - _ => leftVersion.Value >= rightVersion.Value + ">=" => leftVersion.Value >= rightVersion.Value, + _ => throw new ArgumentOutOfRangeException(nameof(operatorName), operatorName, null) }; Assert.Equal(expected, actual); } From 41a9722f522376759eed5a032edac2791c7ef1aa Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Fri, 5 Jun 2026 11:08:26 +0200 Subject: [PATCH 3/6] add changeset for duplication refactor --- .changeset/dry-owls-refactor.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/dry-owls-refactor.md diff --git a/.changeset/dry-owls-refactor.md b/.changeset/dry-owls-refactor.md new file mode 100644 index 00000000..1bf8fb3f --- /dev/null +++ b/.changeset/dry-owls-refactor.md @@ -0,0 +1,5 @@ +--- +"PostHog": patch +--- + +Refactor duplicate internal SDK code paths without changing public API behavior. From 81a8222866fb78a53b4d0cf7f653164a0f12dece Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Fri, 5 Jun 2026 11:55:57 +0200 Subject: [PATCH 4/6] test: cover duplication refactor paths --- tests/UnitTests/PostHogClientTests.cs | 188 ++++++++++++++++++++++++++ 1 file changed, 188 insertions(+) diff --git a/tests/UnitTests/PostHogClientTests.cs b/tests/UnitTests/PostHogClientTests.cs index 4009ae0a..a3ab9ed8 100644 --- a/tests/UnitTests/PostHogClientTests.cs +++ b/tests/UnitTests/PostHogClientTests.cs @@ -13,6 +13,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using PostHog; +using PostHog.Sdk; using PostHog.Versioning; using UnitTests.Fakes; @@ -243,6 +244,65 @@ public async Task SendsCorrectPayload() """, received); } + [Fact] + public async Task CancellationTokenOverloadOverwritesNameProperty() + { + var container = new TestContainer(); + var requestHandler = container.FakeHttpMessageHandler.AddCaptureResponse(); + var client = container.Activate(); + var properties = new Dictionary + { + ["name"] = "Old Name", + ["tier"] = "enterprise" + }; + + var result = await client.GroupIdentifyAsync( + type: "organization", + key: "id:5", + name: "PostHog", + properties, + CancellationToken.None); + + Assert.Equal(1, result.Status); + Assert.Equal("PostHog", properties["name"]); + using var document = JsonDocument.Parse(requestHandler.GetReceivedRequestBody(indented: false)); + var root = document.RootElement; + var groupSet = root.GetProperty("properties").GetProperty("$group_set"); + Assert.Equal("$organization_id:5", root.GetProperty("distinct_id").GetString()); + Assert.Equal("PostHog", groupSet.GetProperty("name").GetString()); + Assert.Equal("enterprise", groupSet.GetProperty("tier").GetString()); + } + + [Fact] + public async Task DistinctIdCancellationTokenOverloadOverwritesNameProperty() + { + var container = new TestContainer(); + var requestHandler = container.FakeHttpMessageHandler.AddCaptureResponse(); + var client = container.Activate(); + var properties = new Dictionary + { + ["name"] = "Old Name", + ["tier"] = "enterprise" + }; + + var result = await client.GroupIdentifyAsync( + distinctId: "custom_distinct_id", + type: "organization", + key: "id:5", + name: "PostHog", + properties, + CancellationToken.None); + + Assert.Equal(1, result.Status); + Assert.Equal("PostHog", properties["name"]); + using var document = JsonDocument.Parse(requestHandler.GetReceivedRequestBody(indented: false)); + var root = document.RootElement; + var groupSet = root.GetProperty("properties").GetProperty("$group_set"); + Assert.Equal("custom_distinct_id", root.GetProperty("distinct_id").GetString()); + Assert.Equal("PostHog", groupSet.GetProperty("name").GetString()); + Assert.Equal("enterprise", groupSet.GetProperty("tier").GetString()); + } + [Fact] // Ported from PostHog/posthog-python test_basic_group_identify public async Task SendsCorrectPayloadWithUserProvidedDistinctId() { @@ -280,6 +340,79 @@ public async Task SendsCorrectPayloadWithUserProvidedDistinctId() } } +public class TheCapturePageViewMethod +{ + [Fact] + public async Task SendFeatureFlagsFalseUsesPageViewPayloadWithoutFeatureFlagProperties() + { + var container = new TestContainer(); + var batchHandler = container.FakeHttpMessageHandler.AddBatchResponse(); + var client = container.Activate(); + + var captured = client.CapturePageView( + distinctId: "distinct-id", + pagePath: "/pricing", + properties: new Dictionary { ["source"] = "test" }, + sendFeatureFlags: false); + + Assert.True(captured); + await client.FlushAsync(); + var batchItem = GetOnlyBatchItem(batchHandler); + var properties = batchItem.GetProperty("properties"); + Assert.Equal("$pageview", batchItem.GetProperty("event").GetString()); + Assert.Equal("distinct-id", batchItem.GetProperty("distinct_id").GetString()); + Assert.Equal("/pricing", properties.GetProperty("$current_url").GetString()); + Assert.Equal("test", properties.GetProperty("source").GetString()); + Assert.False(properties.TryGetProperty("$active_feature_flags", out _)); + Assert.False(properties.TryGetProperty("$feature/flag1", out _)); + } + + [Fact] + public async Task SendFeatureFlagsTrueAddsFeatureFlagPropertiesToPageViewPayload() + { + var container = new TestContainer(); + var batchHandler = container.FakeHttpMessageHandler.AddBatchResponse(); + var flagsHandler = container.FakeHttpMessageHandler.AddFlagsResponse( + """ + { + "featureFlags": { + "flag1": true, + "flag2": false, + "flag3": "variant" + } + } + """); + var client = container.Activate(); + + var captured = client.CapturePageView( + distinctId: "distinct-id", + pagePath: "/pricing", + properties: new Dictionary { ["source"] = "test" }, + sendFeatureFlags: true); + + Assert.True(captured); + await client.FlushAsync(); + Assert.Single(flagsHandler.ReceivedRequests); + var properties = GetOnlyBatchItem(batchHandler).GetProperty("properties"); + Assert.Equal("/pricing", properties.GetProperty("$current_url").GetString()); + Assert.True(properties.GetProperty("$feature/flag1").GetBoolean()); + Assert.False(properties.GetProperty("$feature/flag2").GetBoolean()); + Assert.Equal("variant", properties.GetProperty("$feature/flag3").GetString()); + Assert.Contains( + properties.GetProperty("$active_feature_flags").EnumerateArray(), + flag => flag.GetString() == "flag1"); + Assert.Contains( + properties.GetProperty("$active_feature_flags").EnumerateArray(), + flag => flag.GetString() == "flag3"); + } + + static JsonElement GetOnlyBatchItem(FakeHttpMessageHandler.RequestHandler batchHandler) + { + using var document = JsonDocument.Parse(batchHandler.GetReceivedRequestBody(indented: false)); + return document.RootElement.GetProperty("batch").EnumerateArray().Single().Clone(); + } +} + public class TheCaptureMethod { [Fact] @@ -1529,6 +1662,61 @@ private static void AssertContextEmpty(JsonElement frame) } } +public class TheNoOpPostHogClient +{ + [Fact] + public async Task RefactoredApiMethodsReturnNoOpResults() + { + var client = NoOpPostHogClient.Instance; + + var aliasResult = await client.AliasAsync("previous-id", "new-id", CancellationToken.None); + var identifyResult = await client.IdentifyAsync("distinct-id", null, null, CancellationToken.None); + var groupResult = await client.GroupIdentifyAsync("organization", "id:5", null, CancellationToken.None); + var groupWithDistinctIdResult = await client.GroupIdentifyAsync("distinct-id", "organization", "id:5", null, CancellationToken.None); + + Assert.Equal(0, aliasResult.Status); + Assert.Equal(0, identifyResult.Status); + Assert.Equal(0, groupResult.Status); + Assert.Equal(0, groupWithDistinctIdResult.Status); + } + + [Fact] + public void RefactoredCaptureMethodsReturnFalseWithoutThrowing() + { + var client = NoOpPostHogClient.Instance; + + var capturedWithBooleanFlags = client.Capture( + "distinct-id", + "event-name", + properties: null, + groups: null, + sendFeatureFlags: true); + var capturedWithSnapshot = client.Capture( + "distinct-id", + "event-name", + properties: null, + groups: null, + flags: null); + var capturedExceptionWithBooleanFlags = client.CaptureException( + null!, + "distinct-id", + properties: null, + groups: null, + sendFeatureFlags: true); + var capturedExceptionWithSnapshot = client.CaptureException( + null!, + "distinct-id", + properties: null, + groups: null, + flags: null); + + Assert.False(capturedWithBooleanFlags); + Assert.False(capturedWithSnapshot); + Assert.False(capturedExceptionWithBooleanFlags); + Assert.False(capturedExceptionWithSnapshot); + } +} + public class TheDisabledClient { [Theory] From b1f20fe205a5935b610f113df757c7f056e9dee1 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Fri, 5 Jun 2026 12:24:25 +0200 Subject: [PATCH 5/6] test: make invalid source path stack frame test robust --- tests/UnitTests/PostHogClientTests.cs | 71 +++++++-------------------- 1 file changed, 17 insertions(+), 54 deletions(-) diff --git a/tests/UnitTests/PostHogClientTests.cs b/tests/UnitTests/PostHogClientTests.cs index a3ab9ed8..f08ee0c6 100644 --- a/tests/UnitTests/PostHogClientTests.cs +++ b/tests/UnitTests/PostHogClientTests.cs @@ -1415,67 +1415,30 @@ public static void Boom() public async Task CaptureExceptionWithInvalidFilePathInStackFrame() { var (_, requestHandler, client) = CreateClient(); + var compiledThrower = await CreateDivideByZeroExceptionWithTempSourceFileAsync(); + File.Delete(compiledThrower.SourcePath); - var fakePath = @"fake_file.cs"; - var code = - """ - using System; - public static class Thrower - { - public static void Boom() - { - int zero = 0; - var _ = 1 / zero; - } - } - """; - - // Parse with the fake source path so PDB embeds it - var parse = CSharpParseOptions.Default; - var tree = CSharpSyntaxTree.ParseText(SourceText.From(code, Encoding.UTF8), parse, path: fakePath); - var trustedPlatformAssemblies = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!).Split(Path.PathSeparator); - - var refs = trustedPlatformAssemblies - .Where(p => p.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)) - .GroupBy(Path.GetFileName, StringComparer.OrdinalIgnoreCase) - .Select(g => MetadataReference.CreateFromFile(g.First())); - - var comp = CSharpCompilation.Create( - "ThrowerAsm", - [tree], - refs, - new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, optimizationLevel: OptimizationLevel.Debug)); - - using var pe = new MemoryStream(); - using var pdb = new MemoryStream(); - var emit = comp.Emit(pe, pdb, options: new EmitOptions(debugInformationFormat: DebugInformationFormat.PortablePdb)); - Assert.True(emit.Success, string.Join(Environment.NewLine, emit.Diagnostics)); + client.CaptureException(compiledThrower.Exception, "some-distinct-id"); + await client.FlushAsync(); - pe.Position = 0; - pdb.Position = 0; + var (_, batchItem, props) = ParseSingleEvent(requestHandler.GetReceivedRequestBody(indented: true)); + var divideByZeroException = GetExceptionOfType(props, "System.DivideByZeroException"); + var frames = GetStackFrames(divideByZeroException); + var sourceFrame = frames.FirstOrDefault(f => + f.TryGetProperty("abs_path", out var absPath) && + string.Equals(absPath.GetString(), compiledThrower.SourcePath, StringComparison.Ordinal)); - var assemblyLoadContext = new AssemblyLoadContext("ThrowerCtx", isCollectible: true); - var assembly = assemblyLoadContext.LoadFromStream(pe, pdb); - var boom = assembly.GetType("Thrower")!.GetMethod("Boom", BindingFlags.Public | BindingFlags.Static)!; + Assert.Equal("$exception", batchItem.GetProperty("event").GetString()); - try + // Some runtime/build combinations do not include source file paths in stack frames. + // When source info is absent, this scenario cannot be reproduced. + if (sourceFrame.ValueKind is JsonValueKind.Undefined) { - boom.Invoke(null, null); + return; } - catch (TargetInvocationException tie) when (tie.InnerException is DivideByZeroException ex) - { - client.CaptureException(ex, "some-distinct-id"); - await client.FlushAsync(); - var (_, batchItem, props) = ParseSingleEvent(requestHandler.GetReceivedRequestBody(indented: true)); - - var divideByZeroException = GetExceptionOfType(props, "System.DivideByZeroException"); - var frames = GetStackFrames(divideByZeroException); - - Assert.Equal("$exception", batchItem.GetProperty("event").GetString()); - Assert.Equal(fakePath, frames[0].GetProperty("filename").GetString()); - AssertContextEmpty(frames[0]); - } + Assert.Equal(Path.GetFileName(compiledThrower.SourcePath), sourceFrame.GetProperty("filename").GetString()); + AssertContextEmpty(sourceFrame); } [Fact] From 8f1878eb3ec643d43f4e596d5c1fcb79df86bcec Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Tue, 9 Jun 2026 10:19:29 +0200 Subject: [PATCH 6/6] address pr review feedback --- src/PostHog/NoOpPostHogClient.cs | 4 +- .../UnitTests/Library/SemanticVersionTests.cs | 124 ++++++++++-------- 2 files changed, 74 insertions(+), 54 deletions(-) diff --git a/src/PostHog/NoOpPostHogClient.cs b/src/PostHog/NoOpPostHogClient.cs index 11db0414..f939ca0a 100644 --- a/src/PostHog/NoOpPostHogClient.cs +++ b/src/PostHog/NoOpPostHogClient.cs @@ -32,7 +32,7 @@ public Task GroupIdentifyAsync( StringOrValue key, Dictionary? properties, CancellationToken cancellationToken) - => GroupIdentifyAsync(string.Empty, type, key, properties, cancellationToken); + => NoOpApiResultTask; public Task GroupIdentifyAsync( string distinctId, @@ -76,7 +76,7 @@ public bool CaptureException( GroupCollection? groups, FeatureFlagEvaluations? flags, DateTimeOffset? timestamp = null) - => Capture(distinctId, exception?.GetType().FullName ?? string.Empty, properties, groups, flags, timestamp); + => false; public Task IsFeatureEnabledAsync( string featureKey, diff --git a/tests/UnitTests/Library/SemanticVersionTests.cs b/tests/UnitTests/Library/SemanticVersionTests.cs index 0e0c69bc..ae79d6db 100644 --- a/tests/UnitTests/Library/SemanticVersionTests.cs +++ b/tests/UnitTests/Library/SemanticVersionTests.cs @@ -122,27 +122,80 @@ public void ComparesVersions(string left, string right, int expectedSign) } } -public class TheVersionRangeBoundsMethods +public class TheGetTildeBoundsMethod : RangeBoundsMethodTests { [Theory] // ~X.Y.Z means >=X.Y.Z and AssertBounds(input, expectedLower, expectedUpper); + + [Theory] + // Tilde range matching + [InlineData("1.2.3", "1.2.3", true)] + [InlineData("1.2.3", "1.2.4", true)] + [InlineData("1.2.3", "1.2.99", true)] + [InlineData("1.2.3", "1.3.0", false)] + [InlineData("1.2.3", "1.2.2", false)] + [InlineData("1.2.3", "2.0.0", false)] + public void BoundsMatchCorrectly(string baseVersion, string testVersion, bool expectedInRange) + => AssertInRange(baseVersion, testVersion, expectedInRange); + + private protected override (SemanticVersion Lower, SemanticVersion Upper) GetBounds(SemanticVersion version) + => version.GetTildeBounds(); +} + +public class TheGetCaretBoundsMethod : RangeBoundsMethodTests +{ + [Theory] // ^X.Y.Z where X > 0 → >=X.Y.Z 0 → >=0.Y.Z <0.Y+1.0 - [InlineData("caret", "0.2.3", "0.2.3", "0.3.0")] - [InlineData("caret", "0.1.0", "0.1.0", "0.2.0")] - [InlineData("caret", "0.5.10", "0.5.10", "0.6.0")] + [InlineData("0.2.3", "0.2.3", "0.3.0")] + [InlineData("0.1.0", "0.1.0", "0.2.0")] + [InlineData("0.5.10", "0.5.10", "0.6.0")] // ^0.0.Z → >=0.0.Z <0.0.Z+1 - [InlineData("caret", "0.0.3", "0.0.3", "0.0.4")] - [InlineData("caret", "0.0.0", "0.0.0", "0.0.1")] - [InlineData("caret", "0.0.10", "0.0.10", "0.0.11")] - public void CalculatesCorrectBounds(string kind, string input, string expectedLower, string expectedUpper) + [InlineData("0.0.3", "0.0.3", "0.0.4")] + [InlineData("0.0.0", "0.0.0", "0.0.1")] + [InlineData("0.0.10", "0.0.10", "0.0.11")] + public void CalculatesCorrectBounds(string input, string expectedLower, string expectedUpper) + => AssertBounds(input, expectedLower, expectedUpper); + + [Theory] + // Caret range matching with major > 0 + [InlineData("1.2.3", "1.2.3", true)] + [InlineData("1.2.3", "1.2.4", true)] + [InlineData("1.2.3", "1.9.9", true)] + [InlineData("1.2.3", "2.0.0", false)] + [InlineData("1.2.3", "1.2.2", false)] + [InlineData("1.2.3", "3.0.0", false)] + // Caret range matching with major = 0, minor > 0 + [InlineData("0.2.3", "0.2.3", true)] + [InlineData("0.2.3", "0.2.4", true)] + [InlineData("0.2.3", "0.2.99", true)] + [InlineData("0.2.3", "0.3.0", false)] + [InlineData("0.2.3", "0.2.2", false)] + [InlineData("0.2.3", "1.0.0", false)] + // Caret range matching with major = 0, minor = 0 + [InlineData("0.0.3", "0.0.3", true)] + [InlineData("0.0.3", "0.0.4", false)] + [InlineData("0.0.3", "0.0.2", false)] + [InlineData("0.0.3", "0.1.0", false)] + public void BoundsMatchCorrectly(string baseVersion, string testVersion, bool expectedInRange) + => AssertInRange(baseVersion, testVersion, expectedInRange); + + private protected override (SemanticVersion Lower, SemanticVersion Upper) GetBounds(SemanticVersion version) + => version.GetCaretBounds(); +} + +public abstract class RangeBoundsMethodTests +{ + protected void AssertBounds(string input, string expectedLower, string expectedUpper) { Assert.True(SemanticVersion.TryParse(input, out var version)); Assert.True(SemanticVersion.TryParse(expectedLower, out var expectedLowerVersion)); @@ -152,40 +205,13 @@ public void CalculatesCorrectBounds(string kind, string input, string expectedLo Assert.NotNull(expectedLowerVersion); Assert.NotNull(expectedUpperVersion); - var (lower, upper) = GetBounds(kind, version.Value); + var (lower, upper) = GetBounds(version.Value); Assert.Equal(expectedLowerVersion.Value, lower); Assert.Equal(expectedUpperVersion.Value, upper); } - [Theory] - // Tilde range matching - [InlineData("tilde", "1.2.3", "1.2.3", true)] - [InlineData("tilde", "1.2.3", "1.2.4", true)] - [InlineData("tilde", "1.2.3", "1.2.99", true)] - [InlineData("tilde", "1.2.3", "1.3.0", false)] - [InlineData("tilde", "1.2.3", "1.2.2", false)] - [InlineData("tilde", "1.2.3", "2.0.0", false)] - // Caret range matching with major > 0 - [InlineData("caret", "1.2.3", "1.2.3", true)] - [InlineData("caret", "1.2.3", "1.2.4", true)] - [InlineData("caret", "1.2.3", "1.9.9", true)] - [InlineData("caret", "1.2.3", "2.0.0", false)] - [InlineData("caret", "1.2.3", "1.2.2", false)] - [InlineData("caret", "1.2.3", "3.0.0", false)] - // Caret range matching with major = 0, minor > 0 - [InlineData("caret", "0.2.3", "0.2.3", true)] - [InlineData("caret", "0.2.3", "0.2.4", true)] - [InlineData("caret", "0.2.3", "0.2.99", true)] - [InlineData("caret", "0.2.3", "0.3.0", false)] - [InlineData("caret", "0.2.3", "0.2.2", false)] - [InlineData("caret", "0.2.3", "1.0.0", false)] - // Caret range matching with major = 0, minor = 0 - [InlineData("caret", "0.0.3", "0.0.3", true)] - [InlineData("caret", "0.0.3", "0.0.4", false)] - [InlineData("caret", "0.0.3", "0.0.2", false)] - [InlineData("caret", "0.0.3", "0.1.0", false)] - public void BoundsMatchCorrectly(string kind, string baseVersion, string testVersion, bool expectedInRange) + protected void AssertInRange(string baseVersion, string testVersion, bool expectedInRange) { Assert.True(SemanticVersion.TryParse(baseVersion, out var baseVer)); Assert.True(SemanticVersion.TryParse(testVersion, out var testVer)); @@ -193,19 +219,13 @@ public void BoundsMatchCorrectly(string kind, string baseVersion, string testVer Assert.NotNull(baseVer); Assert.NotNull(testVer); - var (lower, upper) = GetBounds(kind, baseVer.Value); + var (lower, upper) = GetBounds(baseVer.Value); var inRange = testVer.Value.IsInRange(lower, upper); Assert.Equal(expectedInRange, inRange); } - static (SemanticVersion Lower, SemanticVersion Upper) GetBounds(string kind, SemanticVersion version) - => kind switch - { - "tilde" => version.GetTildeBounds(), - "caret" => version.GetCaretBounds(), - _ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null) - }; + private protected abstract (SemanticVersion Lower, SemanticVersion Upper) GetBounds(SemanticVersion version); } public class TheTryParseWildcardMethod