diff --git a/src/PostHog/Api/ComparisonOperator.cs b/src/PostHog/Api/ComparisonOperator.cs
index 881b50fd..1ecd985f 100644
--- a/src/PostHog/Api/ComparisonOperator.cs
+++ b/src/PostHog/Api/ComparisonOperator.cs
@@ -103,5 +103,59 @@ public enum ComparisonOperator
/// Matches if the flag condition evaluates to the specified value.
///
[JsonStringEnumMemberName("flag_evaluates_to")]
- FlagEvaluatesTo
+ FlagEvaluatesTo,
+
+ ///
+ /// Matches if the version exactly equals the filter version.
+ ///
+ [JsonStringEnumMemberName("semver_eq")]
+ SemverEquals,
+
+ ///
+ /// Matches if the version does not equal the filter version.
+ ///
+ [JsonStringEnumMemberName("semver_neq")]
+ SemverNotEquals,
+
+ ///
+ /// Matches if the version is greater than the filter version.
+ ///
+ [JsonStringEnumMemberName("semver_gt")]
+ SemverGreaterThan,
+
+ ///
+ /// Matches if the version is greater than or equal to the filter version.
+ ///
+ [JsonStringEnumMemberName("semver_gte")]
+ SemverGreaterThanOrEquals,
+
+ ///
+ /// Matches if the version is less than the filter version.
+ ///
+ [JsonStringEnumMemberName("semver_lt")]
+ SemverLessThan,
+
+ ///
+ /// Matches if the version is less than or equal to the filter version.
+ ///
+ [JsonStringEnumMemberName("semver_lte")]
+ SemverLessThanOrEquals,
+
+ ///
+ /// Matches if the version is within the tilde range (~X.Y.Z means >=X.Y.Z and <X.Y+1.0).
+ ///
+ [JsonStringEnumMemberName("semver_tilde")]
+ SemverTilde,
+
+ ///
+ /// Matches if the version is within the caret range (^X.Y.Z is compatible-with per semver spec).
+ ///
+ [JsonStringEnumMemberName("semver_caret")]
+ SemverCaret,
+
+ ///
+ /// Matches if the version matches the wildcard pattern (e.g., "1.2.*" means >=1.2.0 and <1.3.0).
+ ///
+ [JsonStringEnumMemberName("semver_wildcard")]
+ SemverWildcard
}
\ No newline at end of file
diff --git a/src/PostHog/Features/LocalEvaluator.cs b/src/PostHog/Features/LocalEvaluator.cs
index 03dd0a93..60564f65 100644
--- a/src/PostHog/Features/LocalEvaluator.cs
+++ b/src/PostHog/Features/LocalEvaluator.cs
@@ -584,6 +584,15 @@ bool MatchProperty(PropertyFilter propertyFilter, string distinctId, Dictionary<
ComparisonOperator.IsSet => true, // We already checked to see that the key exists.
ComparisonOperator.IsDateBefore => value.IsDateBefore(overrideValue, _timeProvider.GetUtcNow()),
ComparisonOperator.IsDateAfter => !value.IsDateBefore(overrideValue, _timeProvider.GetUtcNow()),
+ ComparisonOperator.SemverEquals => value.CompareSemver(overrideValue) == 0,
+ ComparisonOperator.SemverNotEquals => value.CompareSemver(overrideValue) != 0,
+ ComparisonOperator.SemverGreaterThan => value.CompareSemver(overrideValue) > 0,
+ ComparisonOperator.SemverGreaterThanOrEquals => value.CompareSemver(overrideValue) >= 0,
+ ComparisonOperator.SemverLessThan => value.CompareSemver(overrideValue) < 0,
+ ComparisonOperator.SemverLessThanOrEquals => value.CompareSemver(overrideValue) <= 0,
+ ComparisonOperator.SemverTilde => value.IsSemverTildeMatch(overrideValue),
+ ComparisonOperator.SemverCaret => value.IsSemverCaretMatch(overrideValue),
+ ComparisonOperator.SemverWildcard => value.IsSemverWildcardMatch(overrideValue),
null => true, // If no operator is specified, just return true.
_ => throw new InconclusiveMatchException($"Unknown operator: {propertyFilter.Operator}")
};
diff --git a/src/PostHog/Json/PropertyFilterValue.cs b/src/PostHog/Json/PropertyFilterValue.cs
index eb0392f7..24a24c6c 100644
--- a/src/PostHog/Json/PropertyFilterValue.cs
+++ b/src/PostHog/Json/PropertyFilterValue.cs
@@ -6,6 +6,7 @@
using PostHog.Api;
using PostHog.Library;
using static PostHog.Library.Ensure;
+using static PostHog.Library.SemanticVersion;
namespace PostHog.Json;
@@ -254,6 +255,91 @@ or DateOnly
public static bool operator >=(PropertyFilterValue left, object? right) => NotNull(left).CompareTo(right) >= 0;
public static bool operator <=(PropertyFilterValue left, object? right) => NotNull(left).CompareTo(right) <= 0;
+ static SemanticVersion ParseOverrideSemver(object? overrideValue)
+ {
+ var overrideVersionString = overrideValue?.ToString();
+ if (!SemanticVersion.TryParse(overrideVersionString, out var version))
+ {
+ throw new InconclusiveMatchException($"Cannot parse override value '{overrideVersionString}' as a semantic version");
+ }
+ return version.Value;
+ }
+
+ SemanticVersion ParseFilterSemver()
+ {
+ if (!SemanticVersion.TryParse(StringValue, out var version))
+ {
+ throw new InconclusiveMatchException($"Cannot parse filter value '{StringValue}' as a semantic version");
+ }
+ return version.Value;
+ }
+
+ ///
+ /// Compares the override value as a semantic version against this filter value.
+ ///
+ /// The version value from person/group properties.
+ /// A comparison result: negative if override < filter, zero if equal, positive if override > filter.
+ /// Thrown if either value cannot be parsed as a valid semver.
+ public int CompareSemver(object? overrideValue)
+ {
+ var overrideVersion = ParseOverrideSemver(overrideValue);
+ var filterVersion = ParseFilterSemver();
+ return overrideVersion.CompareTo(filterVersion);
+ }
+
+ ///
+ /// Checks if the override value is within the tilde range specified by this filter value.
+ /// ~X.Y.Z means >=X.Y.Z and <X.Y+1.0
+ ///
+ /// The version value from person/group properties.
+ /// true if the override version is within the tilde range.
+ /// Thrown if either value cannot be parsed as a valid semver.
+ public bool IsSemverTildeMatch(object? overrideValue)
+ {
+ var overrideVersion = ParseOverrideSemver(overrideValue);
+ var filterVersion = ParseFilterSemver();
+ var (lower, upper) = filterVersion.GetTildeBounds();
+ return overrideVersion.IsInRange(lower, upper);
+ }
+
+ ///
+ /// Checks if the override value is within the caret range specified by this filter value.
+ /// ^X.Y.Z is compatible-with per semver spec:
+ /// - ^1.2.3 means >=1.2.3 <2.0.0 (major > 0)
+ /// - ^0.2.3 means >=0.2.3 <0.3.0 (major = 0, minor > 0)
+ /// - ^0.0.3 means >=0.0.3 <0.0.4 (major = 0, minor = 0)
+ ///
+ /// The version value from person/group properties.
+ /// true if the override version is within the caret range.
+ /// Thrown if either value cannot be parsed as a valid semver.
+ public bool IsSemverCaretMatch(object? overrideValue)
+ {
+ var overrideVersion = ParseOverrideSemver(overrideValue);
+ var filterVersion = ParseFilterSemver();
+ var (lower, upper) = filterVersion.GetCaretBounds();
+ return overrideVersion.IsInRange(lower, upper);
+ }
+
+ ///
+ /// Checks if the override value matches the wildcard pattern specified by this filter value.
+ /// "X.*" or "X" means >=X.0.0 <X+1.0.0
+ /// "X.Y.*" means >=X.Y.0 <X.Y+1.0
+ ///
+ /// The version value from person/group properties.
+ /// true if the override version matches the wildcard pattern.
+ /// Thrown if either value cannot be parsed.
+ public bool IsSemverWildcardMatch(object? overrideValue)
+ {
+ var overrideVersion = ParseOverrideSemver(overrideValue);
+
+ if (!TryParseWildcard(StringValue, out var lower, out var upper))
+ {
+ throw new InconclusiveMatchException($"Cannot parse filter value '{StringValue}' as a wildcard pattern");
+ }
+
+ return overrideVersion.IsInRange(lower.Value, upper.Value);
+ }
+
public override string ToString()
{
return this switch
diff --git a/src/PostHog/Library/SemanticVersion.cs b/src/PostHog/Library/SemanticVersion.cs
new file mode 100644
index 00000000..7d40088d
--- /dev/null
+++ b/src/PostHog/Library/SemanticVersion.cs
@@ -0,0 +1,335 @@
+using System.Diagnostics.CodeAnalysis;
+
+namespace PostHog.Library;
+
+///
+/// Represents a semantic version (major.minor.patch) for comparison purposes.
+///
+internal readonly record struct SemanticVersion : IComparable
+{
+ ///
+ /// The major version component.
+ ///
+ public int Major { get; }
+
+ ///
+ /// The minor version component.
+ ///
+ public int Minor { get; }
+
+ ///
+ /// The patch version component.
+ ///
+ public int Patch { get; }
+
+ ///
+ /// Creates a new with the specified components.
+ ///
+ public SemanticVersion(int major, int minor, int patch)
+ {
+ Major = major;
+ Minor = minor;
+ Patch = patch;
+ }
+
+ ///
+ /// Tries to parse a version string into a .
+ ///
+ /// The version string to parse.
+ /// The resulting if parsing succeeds.
+ /// true if parsing was successful; otherwise false.
+ ///
+ /// Parsing rules:
+ /// 1. Strip leading/trailing whitespace
+ /// 2. Strip 'v' or 'V' prefix
+ /// 3. Strip pre-release and build metadata (split on '-' or '+', take first part)
+ /// 4. Split on '.' and parse first 3 components as integers
+ /// 5. Default missing components to 0 (e.g., "1.2" → (1, 2, 0), "1" → (1, 0, 0))
+ /// 6. Ignore extra components beyond the third (e.g., "1.2.3.4" → (1, 2, 3))
+ /// 7. Return false for truly invalid input (empty string, non-numeric parts, leading dot)
+ ///
+ public static bool TryParse(string? value, [NotNullWhen(returnValue: true)] out SemanticVersion? version)
+ {
+ version = null;
+
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ return false;
+ }
+
+ // Strip leading/trailing whitespace
+ // value is guaranteed non-null here since IsNullOrWhiteSpace returned false
+ var trimmed = value!.Trim();
+
+ // Strip 'v' or 'V' prefix
+ if (trimmed.Length > 0 && (trimmed[0] == 'v' || trimmed[0] == 'V'))
+ {
+ trimmed = trimmed[1..];
+ }
+
+ if (string.IsNullOrEmpty(trimmed))
+ {
+ return false;
+ }
+
+ // Strip pre-release and build metadata
+ // For '-', only treat as separator if it follows a digit (e.g., "1.2.3-alpha")
+ // This avoids stripping negative signs (e.g., "1.-2.3" should fail, not become "1.")
+ // For '+', always treat as build metadata separator
+ var metadataIndex = -1;
+ for (var i = 0; i < trimmed.Length; i++)
+ {
+ if (trimmed[i] == '+')
+ {
+ metadataIndex = i;
+ break;
+ }
+ if (trimmed[i] == '-' && i > 0 && char.IsDigit(trimmed[i - 1]))
+ {
+ metadataIndex = i;
+ break;
+ }
+ }
+ if (metadataIndex >= 0)
+ {
+ trimmed = trimmed[..metadataIndex];
+ }
+
+ if (string.IsNullOrEmpty(trimmed))
+ {
+ return false;
+ }
+
+ // Check for leading dot (invalid)
+ if (trimmed[0] == '.')
+ {
+ return false;
+ }
+
+ // Split on '.' and parse components
+ var parts = trimmed.Split('.');
+
+ // Parse major (required)
+ if (!int.TryParse(parts[0], out var major))
+ {
+ return false;
+ }
+
+ // Parse minor (optional, defaults to 0)
+ var minor = 0;
+ if (parts.Length > 1 && !string.IsNullOrEmpty(parts[1]) && !int.TryParse(parts[1], out minor))
+ {
+ return false;
+ }
+
+ // Parse patch (optional, defaults to 0)
+ var patch = 0;
+ if (parts.Length > 2 && !string.IsNullOrEmpty(parts[2]) && !int.TryParse(parts[2], out patch))
+ {
+ return false;
+ }
+
+ // Reject negative version components
+ if (major < 0 || minor < 0 || patch < 0)
+ {
+ return false;
+ }
+
+ version = new SemanticVersion(major, minor, patch);
+ return true;
+ }
+
+ ///
+ /// Compares this version to another version.
+ ///
+ public int CompareTo(SemanticVersion other)
+ {
+ var majorComparison = Major.CompareTo(other.Major);
+ if (majorComparison != 0)
+ {
+ return majorComparison;
+ }
+
+ var minorComparison = Minor.CompareTo(other.Minor);
+ if (minorComparison != 0)
+ {
+ return minorComparison;
+ }
+
+ return Patch.CompareTo(other.Patch);
+ }
+
+ ///
+ /// Computes the tilde range bounds for this version.
+ /// ~X.Y.Z means >=X.Y.Z and <X.Y+1.0
+ ///
+ /// A tuple of (lower, upper) bounds where lower is inclusive and upper is exclusive.
+ public (SemanticVersion Lower, SemanticVersion Upper) GetTildeBounds()
+ {
+ var lower = this;
+ var upper = new SemanticVersion(Major, Minor + 1, 0);
+ return (lower, upper);
+ }
+
+ ///
+ /// Computes the caret range bounds for this version.
+ /// ^X.Y.Z is compatible-with per semver spec:
+ /// - ^1.2.3 means >=1.2.3 <2.0.0 (major > 0)
+ /// - ^0.2.3 means >=0.2.3 <0.3.0 (major = 0, minor > 0)
+ /// - ^0.0.3 means >=0.0.3 <0.0.4 (major = 0, minor = 0)
+ ///
+ /// A tuple of (lower, upper) bounds where lower is inclusive and upper is exclusive.
+ public (SemanticVersion Lower, SemanticVersion Upper) GetCaretBounds()
+ {
+ var lower = this;
+ SemanticVersion upper;
+
+ if (Major > 0)
+ {
+ // ^1.2.3 → >=1.2.3 <2.0.0
+ upper = new SemanticVersion(Major + 1, 0, 0);
+ }
+ else if (Minor > 0)
+ {
+ // ^0.2.3 → >=0.2.3 <0.3.0
+ upper = new SemanticVersion(0, Minor + 1, 0);
+ }
+ else
+ {
+ // ^0.0.3 → >=0.0.3 <0.0.4
+ upper = new SemanticVersion(0, 0, Patch + 1);
+ }
+
+ return (lower, upper);
+ }
+
+ ///
+ /// Tries to parse a wildcard pattern and compute its bounds.
+ /// "X.*" or "X" means >=X.0.0 <X+1.0.0
+ /// "X.Y.*" means >=X.Y.0 <X.Y+1.0
+ ///
+ /// The wildcard pattern to parse.
+ /// The lower bound (inclusive).
+ /// The upper bound (exclusive).
+ /// true if the pattern was successfully parsed; otherwise false.
+ public static bool TryParseWildcard(
+ string? pattern,
+ [NotNullWhen(returnValue: true)] out SemanticVersion? lower,
+ [NotNullWhen(returnValue: true)] out SemanticVersion? upper)
+ {
+ lower = null;
+ upper = null;
+
+ if (string.IsNullOrWhiteSpace(pattern))
+ {
+ return false;
+ }
+
+ // pattern is guaranteed non-null here since IsNullOrWhiteSpace returned false
+ var trimmed = pattern!.Trim();
+
+ // Strip 'v' or 'V' prefix
+ if (trimmed.Length > 0 && (trimmed[0] == 'v' || trimmed[0] == 'V'))
+ {
+ trimmed = trimmed[1..];
+ }
+
+ if (string.IsNullOrEmpty(trimmed))
+ {
+ return false;
+ }
+
+ var parts = trimmed.Split('.');
+
+ // Check for leading dot (invalid)
+ if (trimmed[0] == '.')
+ {
+ return false;
+ }
+
+ // Parse based on the pattern structure
+ if (parts.Length == 1)
+ {
+ // "X" pattern - treat as "X.*"
+ // Bare wildcards like "*" and non-numeric values are invalid
+ if (!int.TryParse(parts[0], out var major))
+ {
+ return false;
+ }
+
+ lower = new SemanticVersion(major, 0, 0);
+ upper = new SemanticVersion(major + 1, 0, 0);
+ return true;
+ }
+ else if (parts.Length == 2)
+ {
+ // "X.Y" or "X.*" pattern
+ if (!int.TryParse(parts[0], out var major))
+ {
+ return false;
+ }
+
+ if (parts[1] == "*")
+ {
+ // "X.*" pattern
+ lower = new SemanticVersion(major, 0, 0);
+ upper = new SemanticVersion(major + 1, 0, 0);
+ return true;
+ }
+
+ if (!int.TryParse(parts[1], out var minor))
+ {
+ return false;
+ }
+
+ // "X.Y" without wildcard - treat as "X.Y.*"
+ lower = new SemanticVersion(major, minor, 0);
+ upper = new SemanticVersion(major, minor + 1, 0);
+ return true;
+ }
+ else if (parts.Length >= 3)
+ {
+ // "X.Y.Z" or "X.Y.*" pattern
+ if (!int.TryParse(parts[0], out var major))
+ {
+ return false;
+ }
+
+ if (!int.TryParse(parts[1], out var minor))
+ {
+ return false;
+ }
+
+ if (parts[2] == "*")
+ {
+ // "X.Y.*" pattern
+ lower = new SemanticVersion(major, minor, 0);
+ upper = new SemanticVersion(major, minor + 1, 0);
+ return true;
+ }
+
+ // "X.Y.Z" is not a wildcard pattern
+ return false;
+ }
+
+ return false;
+ }
+
+ ///
+ /// Checks if this version is within the specified range [lower, upper).
+ ///
+ /// The lower bound (inclusive).
+ /// The upper bound (exclusive).
+ /// true if this version is >= lower and < upper.
+ public bool IsInRange(SemanticVersion lower, SemanticVersion upper)
+ {
+ return CompareTo(lower) >= 0 && CompareTo(upper) < 0;
+ }
+
+ public static bool operator <(SemanticVersion left, SemanticVersion right) => left.CompareTo(right) < 0;
+ public static bool operator >(SemanticVersion left, SemanticVersion right) => left.CompareTo(right) > 0;
+ public static bool operator <=(SemanticVersion left, SemanticVersion right) => left.CompareTo(right) <= 0;
+ public static bool operator >=(SemanticVersion left, SemanticVersion right) => left.CompareTo(right) >= 0;
+
+ public override string ToString() => $"{Major}.{Minor}.{Patch}";
+}
diff --git a/tests/UnitTests/Features/LocalEvaluatorTests.cs b/tests/UnitTests/Features/LocalEvaluatorTests.cs
index b5933cf3..fe594cde 100644
--- a/tests/UnitTests/Features/LocalEvaluatorTests.cs
+++ b/tests/UnitTests/Features/LocalEvaluatorTests.cs
@@ -1640,6 +1640,647 @@ public void PropertyFilterEqualityIsSymmetricForDependencyChain()
}
}
+public class TheSemverOperators
+{
+ static LocalEvaluationApiResult CreateFlags(string key, IReadOnlyList properties)
+ {
+ return new LocalEvaluationApiResult
+ {
+ Flags = [
+ new LocalFeatureFlag
+ {
+ Id = 42,
+ TeamId = 23,
+ Name = $"{key}-feature-flag",
+ Key = key,
+ Filters = new FeatureFlagFilters {
+ Groups = [
+ new FeatureFlagGroup
+ {
+ Properties = properties
+ }
+ ]
+ }
+ }
+ ],
+ GroupTypeMapping = new Dictionary()
+ };
+ }
+
+ [Theory]
+ // Basic equality tests
+ [InlineData("1.2.3", ComparisonOperator.SemverEquals, "1.2.3", true)]
+ [InlineData("1.2.3", ComparisonOperator.SemverEquals, "1.2.4", false)]
+ [InlineData("1.2.3", ComparisonOperator.SemverEquals, "1.2.2", false)]
+ [InlineData("v1.2.3", ComparisonOperator.SemverEquals, "1.2.3", true)]
+ [InlineData("1.2.3-alpha", ComparisonOperator.SemverEquals, "1.2.3", true)] // Pre-release stripped
+ [InlineData("1.2.3", ComparisonOperator.SemverEquals, "1.2.3-beta", true)] // Pre-release stripped
+ public void HandlesSemverEqualsOperator(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]
+ // 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
+ [InlineData("0.2.99", ComparisonOperator.SemverCaret, "0.2.3", true)] // Within range
+ [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("01.02.03", ComparisonOperator.SemverEquals, "1.2.3", true)] // Leading zeros
+ [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)
+ {
+ 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]
+ // Invalid version in property value - should throw InconclusiveMatchException
+ [InlineData("not-a-version", ComparisonOperator.SemverEquals)]
+ [InlineData("", ComparisonOperator.SemverEquals)]
+ [InlineData("abc.def.ghi", ComparisonOperator.SemverEquals)]
+ [InlineData(".1.2.3", ComparisonOperator.SemverEquals)]
+ [InlineData("not-a-version", ComparisonOperator.SemverGreaterThan)]
+ [InlineData("", ComparisonOperator.SemverGreaterThan)]
+ public void ThrowsInconclusiveMatchExceptionForInvalidOverrideVersion(string overrideValue, ComparisonOperator comparison)
+ {
+ var flags = CreateFlags(
+ key: "version",
+ properties: [
+ new PropertyFilter
+ {
+ Type = FilterType.Person,
+ Key = "app_version",
+ Value = new PropertyFilterValue("1.2.3"),
+ Operator = comparison
+ }
+ ]
+ );
+ var properties = new Dictionary
+ {
+ ["app_version"] = overrideValue
+ };
+ var localEvaluator = new LocalEvaluator(flags);
+
+ Assert.Throws(() =>
+ localEvaluator.EvaluateFeatureFlag(
+ key: "version",
+ distinctId: "distinct-id",
+ personProperties: properties));
+ }
+
+ [Theory]
+ // Invalid version in filter value - should throw InconclusiveMatchException
+ [InlineData("not-a-version")]
+ [InlineData("")]
+ [InlineData("abc.def.ghi")]
+ [InlineData(".1.2.3")]
+ public void ThrowsInconclusiveMatchExceptionForInvalidFilterVersion(string filterValue)
+ {
+ var flags = CreateFlags(
+ key: "version",
+ properties: [
+ new PropertyFilter
+ {
+ Type = FilterType.Person,
+ Key = "app_version",
+ Value = new PropertyFilterValue(filterValue),
+ Operator = ComparisonOperator.SemverEquals
+ }
+ ]
+ );
+ var properties = new Dictionary
+ {
+ ["app_version"] = "1.2.3"
+ };
+ var localEvaluator = new LocalEvaluator(flags);
+
+ Assert.Throws(() =>
+ localEvaluator.EvaluateFeatureFlag(
+ key: "version",
+ distinctId: "distinct-id",
+ personProperties: properties));
+ }
+
+ [Theory]
+ // Invalid wildcard patterns - should throw InconclusiveMatchException
+ [InlineData("*")]
+ [InlineData("1.2.3")] // Not a wildcard pattern
+ [InlineData("abc.*")]
+ public void ThrowsInconclusiveMatchExceptionForInvalidWildcardPattern(string filterValue)
+ {
+ var flags = CreateFlags(
+ key: "version",
+ properties: [
+ new PropertyFilter
+ {
+ Type = FilterType.Person,
+ Key = "app_version",
+ Value = new PropertyFilterValue(filterValue),
+ Operator = ComparisonOperator.SemverWildcard
+ }
+ ]
+ );
+ var properties = new Dictionary
+ {
+ ["app_version"] = "1.2.3"
+ };
+ var localEvaluator = new LocalEvaluator(flags);
+
+ Assert.Throws(() =>
+ localEvaluator.EvaluateFeatureFlag(
+ key: "version",
+ distinctId: "distinct-id",
+ personProperties: properties));
+ }
+
+ [Fact]
+ public void ReturnsFalseWhenPropertyValueIsNull()
+ {
+ var flags = CreateFlags(
+ key: "version",
+ properties: [
+ new PropertyFilter
+ {
+ Type = FilterType.Person,
+ Key = "app_version",
+ Value = new PropertyFilterValue("1.2.3"),
+ Operator = ComparisonOperator.SemverEquals
+ }
+ ]
+ );
+ var properties = new Dictionary
+ {
+ ["app_version"] = null
+ };
+ var localEvaluator = new LocalEvaluator(flags);
+
+ var result = localEvaluator.EvaluateFeatureFlag(
+ key: "version",
+ distinctId: "distinct-id",
+ personProperties: properties);
+
+ // Null values return false before reaching operator logic
+ Assert.False(result.Value);
+ }
+
+ [Fact]
+ public void ThrowsInconclusiveMatchExceptionWhenPropertyKeyMissing()
+ {
+ var flags = CreateFlags(
+ key: "version",
+ properties: [
+ new PropertyFilter
+ {
+ Type = FilterType.Person,
+ Key = "app_version",
+ Value = new PropertyFilterValue("1.2.3"),
+ Operator = ComparisonOperator.SemverEquals
+ }
+ ]
+ );
+ var properties = new Dictionary
+ {
+ ["other_property"] = "1.2.3"
+ };
+ var localEvaluator = new LocalEvaluator(flags);
+
+ Assert.Throws(() =>
+ localEvaluator.EvaluateFeatureFlag(
+ key: "version",
+ distinctId: "distinct-id",
+ personProperties: properties));
+ }
+}
+
public class TheMatchesDependencyValueMethod
{
[Theory]
diff --git a/tests/UnitTests/Library/SemanticVersionTests.cs b/tests/UnitTests/Library/SemanticVersionTests.cs
new file mode 100644
index 00000000..981b678b
--- /dev/null
+++ b/tests/UnitTests/Library/SemanticVersionTests.cs
@@ -0,0 +1,581 @@
+using PostHog.Library;
+
+namespace SemanticVersionTests;
+
+public class TheTryParseMethod
+{
+ [Theory]
+ // Basic valid versions
+ [InlineData("1.2.3", 1, 2, 3)]
+ [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(" 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)]
+ [InlineData("1.2.3+build", 1, 2, 3)]
+ [InlineData("1.2.3+build.123", 1, 2, 3)]
+ [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]
+ // Leading zeros are parsed as integers
+ [InlineData("01.02.03", 1, 2, 3)]
+ [InlineData("001.002.003", 1, 2, 3)]
+ public void ParsesLeadingZeros(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]
+ // Invalid inputs
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData("v")]
+ [InlineData("V")]
+ [InlineData(".1.2.3")]
+ [InlineData("abc")]
+ [InlineData("1.2.abc")]
+ [InlineData("abc.2.3")]
+ [InlineData("1.abc.3")]
+ [InlineData("not-a-version")]
+ [InlineData("..")]
+ [InlineData("...")]
+ [InlineData("1.-2.3")] // Negative minor
+ [InlineData("-1.2.3")] // Negative major
+ [InlineData("1.2.-3")] // Negative patch
+ public void ReturnsFalseForInvalidInput(string? input)
+ {
+ var result = SemanticVersion.TryParse(input, out var version);
+
+ Assert.False(result);
+ Assert.Null(version);
+ }
+}
+
+public class TheCompareToMethod
+{
+ [Theory]
+ // Equal versions
+ [InlineData("1.2.3", "1.2.3", 0)]
+ [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)
+ {
+ 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)));
+ }
+}
+
+public class TheGetTildeBoundsMethod
+{
+ [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]
+ // ^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)
+ {
+ 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]
+ // 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)
+ {
+ 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);
+ }
+}
+
+public class TheTryParseWildcardMethod
+{
+ [Theory]
+ // "X.*" pattern → >=X.0.0 =X.Y.0 =X.0.0 =X.Y.0 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);
+ }
+}
+
+public class TheToStringMethod
+{
+ [Theory]
+ [InlineData(1, 2, 3, "1.2.3")]
+ [InlineData(0, 0, 0, "0.0.0")]
+ [InlineData(10, 20, 30, "10.20.30")]
+ public void ReturnsCorrectFormat(int major, int minor, int patch, string expected)
+ {
+ var version = new SemanticVersion(major, minor, patch);
+
+ Assert.Equal(expected, version.ToString());
+ }
+}