From 5c34b8c092c9a4f434db810cda07112bf1c79ef2 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang <141655842+zhiyuanliang-ms@users.noreply.github.com> Date: Thu, 14 Mar 2024 14:37:34 +0800 Subject: [PATCH 01/10] Update constructors of built-in feature filters (#386) * make logger optional * make TargetingEvaluationOptions optional --- .../FeatureFilters/PercentageFilter.cs | 9 ++++++--- .../FeatureFilters/TimeWindowFilter.cs | 9 ++++++--- .../Targeting/ContextualTargetingFilter.cs | 6 +++--- .../Targeting/TargetingFilter.cs | 9 ++++++--- 4 files changed, 21 insertions(+), 12 deletions(-) diff --git a/src/Microsoft.FeatureManagement/FeatureFilters/PercentageFilter.cs b/src/Microsoft.FeatureManagement/FeatureFilters/PercentageFilter.cs index c7ea8840..36e6d5e1 100644 --- a/src/Microsoft.FeatureManagement/FeatureFilters/PercentageFilter.cs +++ b/src/Microsoft.FeatureManagement/FeatureFilters/PercentageFilter.cs @@ -21,9 +21,9 @@ public class PercentageFilter : IFeatureFilter, IFilterParametersBinder /// Creates a percentage based feature filter. /// /// A logger factory for creating loggers. - public PercentageFilter(ILoggerFactory loggerFactory) + public PercentageFilter(ILoggerFactory loggerFactory = null) { - _logger = loggerFactory.CreateLogger(); + _logger = loggerFactory?.CreateLogger(); } /// @@ -51,7 +51,10 @@ public Task EvaluateAsync(FeatureFilterEvaluationContext context) if (settings.Value < 0) { - _logger.LogWarning($"The '{Alias}' feature filter does not have a valid '{nameof(settings.Value)}' value for feature '{context.FeatureName}'"); + if (_logger != null) + { + _logger.LogWarning($"The '{Alias}' feature filter does not have a valid '{nameof(settings.Value)}' value for feature '{context.FeatureName}'"); + } result = false; } diff --git a/src/Microsoft.FeatureManagement/FeatureFilters/TimeWindowFilter.cs b/src/Microsoft.FeatureManagement/FeatureFilters/TimeWindowFilter.cs index 884332b3..6f6d49f4 100644 --- a/src/Microsoft.FeatureManagement/FeatureFilters/TimeWindowFilter.cs +++ b/src/Microsoft.FeatureManagement/FeatureFilters/TimeWindowFilter.cs @@ -21,9 +21,9 @@ public class TimeWindowFilter : IFeatureFilter, IFilterParametersBinder /// Creates a time window based feature filter. /// /// A logger factory for creating loggers. - public TimeWindowFilter(ILoggerFactory loggerFactory) + public TimeWindowFilter(ILoggerFactory loggerFactory = null) { - _logger = loggerFactory.CreateLogger(); + _logger = loggerFactory?.CreateLogger(); } /// @@ -51,7 +51,10 @@ public Task EvaluateAsync(FeatureFilterEvaluationContext context) if (!settings.Start.HasValue && !settings.End.HasValue) { - _logger.LogWarning($"The '{Alias}' feature filter is not valid for feature '{context.FeatureName}'. It must have have specify either '{nameof(settings.Start)}', '{nameof(settings.End)}', or both."); + if (_logger != null) + { + _logger.LogWarning($"The '{Alias}' feature filter is not valid for feature '{context.FeatureName}'. It must have have specify either '{nameof(settings.Start)}', '{nameof(settings.End)}', or both."); + } return Task.FromResult(false); } diff --git a/src/Microsoft.FeatureManagement/Targeting/ContextualTargetingFilter.cs b/src/Microsoft.FeatureManagement/Targeting/ContextualTargetingFilter.cs index d29cafcd..2310d380 100644 --- a/src/Microsoft.FeatureManagement/Targeting/ContextualTargetingFilter.cs +++ b/src/Microsoft.FeatureManagement/Targeting/ContextualTargetingFilter.cs @@ -28,10 +28,10 @@ public class ContextualTargetingFilter : IContextualFeatureFilter /// Options controlling the behavior of the targeting evaluation performed by the filter. /// A logger factory for creating loggers. - public ContextualTargetingFilter(IOptions options, ILoggerFactory loggerFactory) + public ContextualTargetingFilter(IOptions options = null, ILoggerFactory loggerFactory = null) { - _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); - _logger = loggerFactory?.CreateLogger() ?? throw new ArgumentNullException(nameof(loggerFactory)); + _options = options?.Value ?? new TargetingEvaluationOptions(); + _logger = loggerFactory?.CreateLogger(); } private StringComparison ComparisonType => _options.IgnoreCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; diff --git a/src/Microsoft.FeatureManagement/Targeting/TargetingFilter.cs b/src/Microsoft.FeatureManagement/Targeting/TargetingFilter.cs index d57e4b31..77575958 100644 --- a/src/Microsoft.FeatureManagement/Targeting/TargetingFilter.cs +++ b/src/Microsoft.FeatureManagement/Targeting/TargetingFilter.cs @@ -26,11 +26,11 @@ public class TargetingFilter : IFeatureFilter, IFilterParametersBinder /// Options controlling the behavior of the targeting evaluation performed by the filter. /// An accessor used to acquire the targeting context for use in feature evaluation. /// A logger factory for creating loggers. - public TargetingFilter(IOptions options, ITargetingContextAccessor contextAccessor, ILoggerFactory loggerFactory) + public TargetingFilter(ITargetingContextAccessor contextAccessor, IOptions options = null, ILoggerFactory loggerFactory = null) { _contextAccessor = contextAccessor ?? throw new ArgumentNullException(nameof(contextAccessor)); _contextualFilter = new ContextualTargetingFilter(options, loggerFactory); - _logger = loggerFactory?.CreateLogger() ?? throw new ArgumentNullException(nameof(loggerFactory)); + _logger = loggerFactory?.CreateLogger(); } /// @@ -64,7 +64,10 @@ public async Task EvaluateAsync(FeatureFilterEvaluationContext context) // Ensure targeting can be performed if (targetingContext == null) { - _logger.LogWarning("No targeting context available for targeting evaluation."); + if (_logger != null) + { + _logger.LogWarning("No targeting context available for targeting evaluation."); + } return false; } From 1f3eca4e157bb10a7d9d403561ed67d2923adecb Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang <141655842+zhiyuanliang-ms@users.noreply.github.com> Date: Thu, 14 Mar 2024 16:23:35 +0800 Subject: [PATCH 02/10] update (#400) --- .../FeatureFilters/PercentageFilter.cs | 5 +---- .../FeatureFilters/TimeWindowFilter.cs | 5 +---- src/Microsoft.FeatureManagement/Targeting/TargetingFilter.cs | 5 +---- 3 files changed, 3 insertions(+), 12 deletions(-) diff --git a/src/Microsoft.FeatureManagement/FeatureFilters/PercentageFilter.cs b/src/Microsoft.FeatureManagement/FeatureFilters/PercentageFilter.cs index 36e6d5e1..c1d4b9a5 100644 --- a/src/Microsoft.FeatureManagement/FeatureFilters/PercentageFilter.cs +++ b/src/Microsoft.FeatureManagement/FeatureFilters/PercentageFilter.cs @@ -51,10 +51,7 @@ public Task EvaluateAsync(FeatureFilterEvaluationContext context) if (settings.Value < 0) { - if (_logger != null) - { - _logger.LogWarning($"The '{Alias}' feature filter does not have a valid '{nameof(settings.Value)}' value for feature '{context.FeatureName}'"); - } + _logger?.LogWarning($"The '{Alias}' feature filter does not have a valid '{nameof(settings.Value)}' value for feature '{context.FeatureName}'"); result = false; } diff --git a/src/Microsoft.FeatureManagement/FeatureFilters/TimeWindowFilter.cs b/src/Microsoft.FeatureManagement/FeatureFilters/TimeWindowFilter.cs index 6f6d49f4..6765830b 100644 --- a/src/Microsoft.FeatureManagement/FeatureFilters/TimeWindowFilter.cs +++ b/src/Microsoft.FeatureManagement/FeatureFilters/TimeWindowFilter.cs @@ -51,10 +51,7 @@ public Task EvaluateAsync(FeatureFilterEvaluationContext context) if (!settings.Start.HasValue && !settings.End.HasValue) { - if (_logger != null) - { - _logger.LogWarning($"The '{Alias}' feature filter is not valid for feature '{context.FeatureName}'. It must have have specify either '{nameof(settings.Start)}', '{nameof(settings.End)}', or both."); - } + _logger?.LogWarning($"The '{Alias}' feature filter is not valid for feature '{context.FeatureName}'. It must have have specify either '{nameof(settings.Start)}', '{nameof(settings.End)}', or both."); return Task.FromResult(false); } diff --git a/src/Microsoft.FeatureManagement/Targeting/TargetingFilter.cs b/src/Microsoft.FeatureManagement/Targeting/TargetingFilter.cs index 77575958..05e880f3 100644 --- a/src/Microsoft.FeatureManagement/Targeting/TargetingFilter.cs +++ b/src/Microsoft.FeatureManagement/Targeting/TargetingFilter.cs @@ -64,10 +64,7 @@ public async Task EvaluateAsync(FeatureFilterEvaluationContext context) // Ensure targeting can be performed if (targetingContext == null) { - if (_logger != null) - { - _logger.LogWarning("No targeting context available for targeting evaluation."); - } + _logger?.LogWarning("No targeting context available for targeting evaluation."); return false; } From 5dfdb23c22eecc6662afc4700966113b20e1f822 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang <141655842+zhiyuanliang-ms@users.noreply.github.com> Date: Thu, 14 Mar 2024 17:21:31 +0800 Subject: [PATCH 03/10] fix typo in warning message in time window filter (#401) --- .../FeatureFilters/TimeWindowFilter.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Microsoft.FeatureManagement/FeatureFilters/TimeWindowFilter.cs b/src/Microsoft.FeatureManagement/FeatureFilters/TimeWindowFilter.cs index 6765830b..079b8172 100644 --- a/src/Microsoft.FeatureManagement/FeatureFilters/TimeWindowFilter.cs +++ b/src/Microsoft.FeatureManagement/FeatureFilters/TimeWindowFilter.cs @@ -51,7 +51,7 @@ public Task EvaluateAsync(FeatureFilterEvaluationContext context) if (!settings.Start.HasValue && !settings.End.HasValue) { - _logger?.LogWarning($"The '{Alias}' feature filter is not valid for feature '{context.FeatureName}'. It must have have specify either '{nameof(settings.Start)}', '{nameof(settings.End)}', or both."); + _logger?.LogWarning($"The '{Alias}' feature filter is not valid for feature '{context.FeatureName}'. It must specify either '{nameof(settings.Start)}', '{nameof(settings.End)}', or both."); return Task.FromResult(false); } From 0d243b6724289cb25402ae87016e3b6040453266 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang <141655842+zhiyuanliang-ms@users.noreply.github.com> Date: Tue, 19 Mar 2024 10:10:55 +0800 Subject: [PATCH 04/10] Update FeatureGateAttribute.cs (#402) --- .../FeatureGateAttribute.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Microsoft.FeatureManagement.AspNetCore/FeatureGateAttribute.cs b/src/Microsoft.FeatureManagement.AspNetCore/FeatureGateAttribute.cs index c2573b8c..fb15e5b1 100644 --- a/src/Microsoft.FeatureManagement.AspNetCore/FeatureGateAttribute.cs +++ b/src/Microsoft.FeatureManagement.AspNetCore/FeatureGateAttribute.cs @@ -90,12 +90,12 @@ public FeatureGateAttribute(RequirementType requirementType, params object[] fea public IEnumerable Features { get; } /// - /// Controls whether any or all features in should be enabled to pass. + /// Controls whether any or all features in should be enabled to pass. /// public RequirementType RequirementType { get; } /// - /// Performs controller action pre-procesing to ensure that at least one of the specified features are enabled. + /// Performs controller action pre-procesing to ensure that any or all of the specified features are enabled. /// /// The context of the MVC action. /// The action delegate. From 709bbebd89e2923f820f75e651fd39778df26cbe Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang <141655842+zhiyuanliang-ms@users.noreply.github.com> Date: Wed, 20 Mar 2024 16:10:15 +0800 Subject: [PATCH 05/10] update the way to refer feature flag (#404) --- .../FeatureManagementAspNetCore.cs | 8 ++++---- tests/Tests.FeatureManagement.AspNetCore/Features.cs | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/Tests.FeatureManagement.AspNetCore/FeatureManagementAspNetCore.cs b/tests/Tests.FeatureManagement.AspNetCore/FeatureManagementAspNetCore.cs index 62919797..3e5d6aa0 100644 --- a/tests/Tests.FeatureManagement.AspNetCore/FeatureManagementAspNetCore.cs +++ b/tests/Tests.FeatureManagement.AspNetCore/FeatureManagementAspNetCore.cs @@ -36,12 +36,12 @@ public async Task Integrates() services.AddMvcCore(o => { DisableEndpointRouting(o); - o.Filters.AddForFeature(Enum.GetName(typeof(Features), Features.ConditionalFeature)); + o.Filters.AddForFeature(Features.ConditionalFeature); }); }) .Configure(app => { - app.UseForFeature(Enum.GetName(typeof(Features), Features.ConditionalFeature), a => a.Use(async (ctx, next) => + app.UseForFeature(Features.ConditionalFeature, a => a.Use(async (ctx, next) => { ctx.Response.Headers[nameof(RouterMiddleware)] = bool.TrueString; @@ -102,7 +102,7 @@ public async Task GatesFeatures() // // Enable 1/2 features - testFeatureFilter.Callback = ctx => Task.FromResult(ctx.FeatureName == Enum.GetName(typeof(Features), Features.ConditionalFeature)); + testFeatureFilter.Callback = ctx => Task.FromResult(ctx.FeatureName == Features.ConditionalFeature); gateAllResponse = await testServer.CreateClient().GetAsync("gateAll"); gateAnyResponse = await testServer.CreateClient().GetAsync("gateAny"); @@ -158,7 +158,7 @@ public async Task GatesRazorPageFeatures() // // Enable 1/2 features - testFeatureFilter.Callback = ctx => Task.FromResult(ctx.FeatureName == Enum.GetName(typeof(Features), Features.ConditionalFeature)); + testFeatureFilter.Callback = ctx => Task.FromResult(ctx.FeatureName == Features.ConditionalFeature); gateAllResponse = await testServer.CreateClient().GetAsync("RazorTestAll"); gateAnyResponse = await testServer.CreateClient().GetAsync("RazorTestAny"); diff --git a/tests/Tests.FeatureManagement.AspNetCore/Features.cs b/tests/Tests.FeatureManagement.AspNetCore/Features.cs index 5d26be98..d3397a05 100644 --- a/tests/Tests.FeatureManagement.AspNetCore/Features.cs +++ b/tests/Tests.FeatureManagement.AspNetCore/Features.cs @@ -3,9 +3,9 @@ // namespace Tests.FeatureManagement.AspNetCore { - enum Features + static class Features { - ConditionalFeature, - ConditionalFeature2 + public const string ConditionalFeature = "ConditionalFeature"; + public const string ConditionalFeature2 = "ConditionalFeature2"; } } From a63856c84c9f83d6b1b7f196e10c481ca0304fef Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang <141655842+zhiyuanliang-ms@users.noreply.github.com> Date: Wed, 17 Apr 2024 10:34:05 +0800 Subject: [PATCH 06/10] add context null check for built-in filters (#428) --- .../FeatureFilters/PercentageFilter.cs | 6 ++++++ .../FeatureFilters/TimeWindowFilter.cs | 5 +++++ 2 files changed, 11 insertions(+) diff --git a/src/Microsoft.FeatureManagement/FeatureFilters/PercentageFilter.cs b/src/Microsoft.FeatureManagement/FeatureFilters/PercentageFilter.cs index c1d4b9a5..c986e6d9 100644 --- a/src/Microsoft.FeatureManagement/FeatureFilters/PercentageFilter.cs +++ b/src/Microsoft.FeatureManagement/FeatureFilters/PercentageFilter.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Microsoft.FeatureManagement.Utils; +using System; using System.Threading.Tasks; namespace Microsoft.FeatureManagement.FeatureFilters @@ -43,6 +44,11 @@ public object BindParameters(IConfiguration filterParameters) /// True if the feature is enabled, false otherwise. public Task EvaluateAsync(FeatureFilterEvaluationContext context) { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + // // Check if prebound settings available, otherwise bind from parameters. PercentageFilterSettings settings = (PercentageFilterSettings)context.Settings ?? (PercentageFilterSettings)BindParameters(context.Parameters); diff --git a/src/Microsoft.FeatureManagement/FeatureFilters/TimeWindowFilter.cs b/src/Microsoft.FeatureManagement/FeatureFilters/TimeWindowFilter.cs index 079b8172..9993cdc9 100644 --- a/src/Microsoft.FeatureManagement/FeatureFilters/TimeWindowFilter.cs +++ b/src/Microsoft.FeatureManagement/FeatureFilters/TimeWindowFilter.cs @@ -43,6 +43,11 @@ public object BindParameters(IConfiguration filterParameters) /// True if the feature is enabled, false otherwise. public Task EvaluateAsync(FeatureFilterEvaluationContext context) { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + // // Check if prebound settings available, otherwise bind from parameters. TimeWindowFilterSettings settings = (TimeWindowFilterSettings)context.Settings ?? (TimeWindowFilterSettings)BindParameters(context.Parameters); From 44033d225d24cbf7fe47de528c49f7de028765e9 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang <141655842+zhiyuanliang-ms@users.noreply.github.com> Date: Tue, 23 Apr 2024 11:21:06 +0800 Subject: [PATCH 07/10] Support recurrence for TimeWindowFilter (#266) * improvement & target on main branch * remove duplicated null check * remove redundant if * improvement * resolve comments * fix typo * update * use enum * Update testcases * fix bug * update * update the logic of FindWeeklyPreviousOccurrence * fix bug * add comments * update * fix bug & add testcases * update * update comment * test * update comments * update * update * add testcase * remove monthly/yearly recurrence pattern * do not mention monthly and yearly pattern * add more comments * update the algorithm to find weekly previous occurrence * update * fix typo * update * rename variable * cache added & do validation for only once * add comments * add more testcases * add more test * not include the end of a time window * move recurrence validation to RecurrenceValidator * README updated * update readme * update CalculateSurroundingOccurrences method * add CalculateClosestStart method * testcase updated * update * use ISystemClock for testing & add limit on time window duration * add testcase for timezone * update * update comments * change method type * remove unused reference * rename variable * remove used reference * remove empty lines --- README.md | 144 +- .../FeatureFilters/ISystemClock.cs | 20 + .../FeatureFilters/Recurrence/Recurrence.cs | 21 + .../Recurrence/RecurrenceEvaluator.cs | 361 ++++ .../Recurrence/RecurrencePattern.cs | 34 + .../Recurrence/RecurrencePatternType.cs | 21 + .../Recurrence/RecurrenceRange.cs | 28 + .../Recurrence/RecurrenceRangeType.cs | 26 + .../Recurrence/RecurrenceValidator.cs | 431 ++++ .../FeatureFilters/TimeWindowFilter.cs | 90 +- .../TimeWindowFilterSettings.cs | 10 +- .../FeatureManagementBuilder.cs | 32 + .../ServiceCollectionExtensions.cs | 50 +- .../FeatureManagement.cs | 29 +- tests/Tests.FeatureManagement/Features.cs | 1 + .../Tests.FeatureManagement/OnDemandClock.cs | 10 + .../RecurrenceEvaluation.cs | 1740 +++++++++++++++++ .../Tests.FeatureManagement/appsettings.json | 31 +- 18 files changed, 3046 insertions(+), 33 deletions(-) create mode 100644 src/Microsoft.FeatureManagement/FeatureFilters/ISystemClock.cs create mode 100644 src/Microsoft.FeatureManagement/FeatureFilters/Recurrence/Recurrence.cs create mode 100644 src/Microsoft.FeatureManagement/FeatureFilters/Recurrence/RecurrenceEvaluator.cs create mode 100644 src/Microsoft.FeatureManagement/FeatureFilters/Recurrence/RecurrencePattern.cs create mode 100644 src/Microsoft.FeatureManagement/FeatureFilters/Recurrence/RecurrencePatternType.cs create mode 100644 src/Microsoft.FeatureManagement/FeatureFilters/Recurrence/RecurrenceRange.cs create mode 100644 src/Microsoft.FeatureManagement/FeatureFilters/Recurrence/RecurrenceRangeType.cs create mode 100644 src/Microsoft.FeatureManagement/FeatureFilters/Recurrence/RecurrenceValidator.cs create mode 100644 tests/Tests.FeatureManagement/OnDemandClock.cs create mode 100644 tests/Tests.FeatureManagement/RecurrenceEvaluation.cs diff --git a/README.md b/README.md index 61e63032..c8e59665 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ The feature management library supports appsettings.json as a feature flag sourc "Name": "TimeWindow", "Parameters": { "Start": "Wed, 01 May 2019 13:59:59 GMT", - "End": "Mon, 01 July 2019 00:00:00 GMT" + "End": "Mon, 01 Jul 2019 00:00:00 GMT" } } ] @@ -131,7 +131,7 @@ A `RequirementType` of `All` changes the traversal. First, if there are no filte "Name": "TimeWindow", "Parameters": { "Start": "Mon, 01 May 2023 13:59:59 GMT", - "End": "Sat, 01 July 2023 00:00:00 GMT" + "End": "Sat, 01 Jul 2023 00:00:00 GMT" } }, { @@ -163,7 +163,7 @@ The feature management library also supports the usage of the [`Microsoft Featur "name": "Microsoft.TimeWindow", "parameters": { "Start": "Mon, 01 May 2023 13:59:59 GMT", - "End": "Sat, 01 July 2023 00:00:00 GMT" + "End": "Sat, 01 Jul 2023 00:00:00 GMT" } } ] @@ -565,13 +565,149 @@ This filter provides the capability to enable a feature based on a time window. "Name": "Microsoft.TimeWindow", "Parameters": { "Start": "Wed, 01 May 2019 13:59:59 GMT", - "End": "Mon, 01 July 2019 00:00:00 GMT" + "End": "Mon, 01 Jul 2019 00:00:00 GMT" } } ] } ``` +The time window can be configured to recur periodically. This can be useful for the scenarios where one may need to turn on a feature during a low or high traffic period of a day or certain days of a week. To expand the individual time window to recurring time windows, the recurrence rule should be specified in the `Recurrence` parameter. + +**Note:** `Start` and `End` must be both specified to enable `Recurrence`. + +``` JavaScript +"EnhancedPipeline": { + "EnabledFor": [ + { + "Name": "Microsoft.TimeWindow", + "Parameters": { + "Start": "Fri, 22 Mar 2024 20:00:00 GMT", + "End": "Sat, 23 Mar 2024 02:00:00 GMT", + "Recurrence": { + "Pattern": { + "Type": "Daily", + "Interval": 1 + }, + "Range": { + "Type": "NoEnd" + } + } + } + } + ] +} +``` + +The `Recurrence` settings is made up of two parts: `Pattern` (how often the time window will repeat) and `Range` (for how long the recurrence pattern will repeat). + +#### Recurrence Pattern + +There are two possible recurrence pattern types: `Daily` and `Weekly`. For example, a time window could repeat "every day", "every 3 days", "every Monday" or "on Friday per 2 weeks". + +Depending on the type, certain fields of the `Pattern` are required, optional, or ignored. + +- `Daily` + + The daily recurrence pattern causes the time window to repeat based on a number of days between each occurrence. + + | Property | Relevance | Description | + |----------|-----------|-------------| + | **Type** | Required | Must be set to `Daily`. | + | **Interval** | Optional | Specifies the number of days between each occurrence. Default value is 1. | + +- `Weekly` + + The weekly recurrence pattern causes the time window to repeat on the same day or days of the week, based on the number of weeks between each set of occurrences. + + | Property | Relevance | Description | + |----------|-----------|-------------| + | **Type** | Required | Must be set to `Weekly`. | + | **DaysOfWeek** | Required | Specifies on which day(s) of the week the event occurs. | + | **Interval** | Optional | Specifies the number of weeks between each set of occurrences. Default value is 1. | + | **FirstDayOfWeek** | Optional | Specifies which day is considered the first day of the week. Default value is `Sunday`. | + + The following example will repeat the time window every other Monday and Tuesday + + ``` javascript + "Pattern": { + "Type": "Weekly", + "Interval": 2, + "DaysOfWeek": ["Monday", "Tuesday"] + } + ``` + +**Note:** `Start` must be a valid first occurrence which fits the recurrence pattern. Additionally, the duration of the time window cannot be longer than how frequently it occurs. For example, it is invalid to have a 25-hour time window recur every day. + +#### Recurrence Range + +There are three possible recurrence range type: `NoEnd`, `EndDate` and `Numbered`. + +- `NoEnd` + + The `NoEnd` range causes the recurrence to occur indefinitely. + + | Property | Relevance | Description | + |----------|-----------|-------------| + | **Type** | Required | Must be set to `NoEnd`. | + +- `EndDate` + + The `EndDate` range causes the time window to occur on all days that fit the applicable pattern until the end date. + + | Property | Relevance | Description | + |----------|-----------|-------------| + | **Type** | Required | Must be set to `EndDate`. | + | **EndDate** | Required | Specifies the date time to stop applying the pattern. Note that as long as the start time of the last occurrence falls before the end date, the end time of that occurrence is allowed to extend beyond it. | + + The following example will repeat the time window every day until the last occurrence happens on April 1st, 2024. + + ``` javascript + "Start": "Fri, 22 Mar 2024 18:00:00 GMT", + "End": "Fri, 22 Mar 2024 20:00:00 GMT", + "Recurrence":{ + "Pattern": { + "Type": "Daily", + "Interval": 1 + }, + "Range": { + "Type": "EndDate", + "EndDate": "Mon, 1 Apr 2024 20:00:00 GMT" + } + } + ``` + +- `Numbered` + + The `Numbered` range causes the time window to occur a fixed number of times (based on the pattern). + + | Property | Relevance | Description | + |----------|-----------|-------------| + | **Type** | Required | Must be set to `Numbered`. | + | **NumberOfOccurrences** | Required | Specifies the number of occurrences. | + + The following example will repeat the time window on Monday and Tuesday until the there are 3 occurrences, which respectively happens on April 1st(Mon), April 2nd(Tue) and April 8th(Mon). + + ``` javascript + "Start": "Mon, 1 Apr 2024 18:00:00 GMT", + "End": "Mon, 1 Apr 2024 20:00:00 GMT", + "Recurrence":{ + "Pattern": { + "Type": "Weekly", + "Interval": 1 + "DaysOfWeek": ["Monday", "Tuesday"], + }, + "Range": { + "Type": "Numbered", + "NumberOfOccurrences": 3 + } + } + ``` + +To create a recurrence rule, you must specify both `Pattern` and `Range`. Any pattern type can work with any range type. + +**Advanced:** The time zone offset of the `Start` property will apply to the recurrence settings. + ### Microsoft.Targeting This filter provides the capability to enable a feature for a target audience. An in-depth explanation of targeting is explained in the [targeting](./README.md#Targeting) section below. The filter parameters include an audience object which describes users, groups, excluded users/groups, and a default percentage of the user base that should have access to the feature. Each group object that is listed in the target audience must also specify what percentage of the group's members should have access. If a user is specified in the exclusion section, either directly or if the user is in an excluded group, the feature will be disabled. Otherwise, if a user is specified in the users section directly, or if the user is in the included percentage of any of the group rollouts, or if the user falls into the default rollout percentage then that user will have the feature enabled. diff --git a/src/Microsoft.FeatureManagement/FeatureFilters/ISystemClock.cs b/src/Microsoft.FeatureManagement/FeatureFilters/ISystemClock.cs new file mode 100644 index 00000000..50c6743d --- /dev/null +++ b/src/Microsoft.FeatureManagement/FeatureFilters/ISystemClock.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// + +using System; + +namespace Microsoft.FeatureManagement.FeatureFilters +{ + /// + /// Abstracts the system clock to facilitate testing. + /// .NET8 offers an abstract class TimeProvider. After we stop supporting .NET version less than .NET8, this ISystemClock should retire. + /// + internal interface ISystemClock + { + /// + /// Retrieves the current system time in UTC. + /// + public DateTimeOffset UtcNow { get; } + } +} \ No newline at end of file diff --git a/src/Microsoft.FeatureManagement/FeatureFilters/Recurrence/Recurrence.cs b/src/Microsoft.FeatureManagement/FeatureFilters/Recurrence/Recurrence.cs new file mode 100644 index 00000000..7a63b6bb --- /dev/null +++ b/src/Microsoft.FeatureManagement/FeatureFilters/Recurrence/Recurrence.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +namespace Microsoft.FeatureManagement.FeatureFilters +{ + /// + /// A recurrence definition describing how time window recurs + /// + public class Recurrence + { + /// + /// The recurrence pattern specifying how often the time window repeats + /// + public RecurrencePattern Pattern { get; set; } + + /// + /// The recurrence range specifying how long the recurrence pattern repeats + /// + public RecurrenceRange Range { get; set; } + } +} diff --git a/src/Microsoft.FeatureManagement/FeatureFilters/Recurrence/RecurrenceEvaluator.cs b/src/Microsoft.FeatureManagement/FeatureFilters/Recurrence/RecurrenceEvaluator.cs new file mode 100644 index 00000000..4dba080a --- /dev/null +++ b/src/Microsoft.FeatureManagement/FeatureFilters/Recurrence/RecurrenceEvaluator.cs @@ -0,0 +1,361 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; + +namespace Microsoft.FeatureManagement.FeatureFilters +{ + static class RecurrenceEvaluator + { + const int DaysPerWeek = 7; + + /// + /// Checks if a provided timestamp is within any recurring time window specified by the Recurrence section in the time window filter settings. + /// If the time window filter has an invalid recurrence setting, an exception will be thrown. + /// A datetime. + /// The settings of time window filter. + /// True if the timestamp is within any recurring time window, false otherwise. + /// + public static bool IsMatch(DateTimeOffset time, TimeWindowFilterSettings settings) + { + Debug.Assert(settings != null); + Debug.Assert(settings.Start != null); + Debug.Assert(settings.Recurrence != null); + Debug.Assert(settings.Recurrence.Pattern != null); + Debug.Assert(settings.Recurrence.Range != null); + + if (time < settings.Start.Value) + { + return false; + } + + if (TryFindPreviousOccurrence(time, settings, out DateTimeOffset previousOccurrence, out int _)) + { + return time < previousOccurrence + (settings.End.Value - settings.Start.Value); + } + + return false; + } + + /// + /// Calculates the start time of the closest active time window. + /// A datetime. + /// The settings of time window filter. + /// The start time of the closest active time window or null if the recurrence range surpasses its end. + /// + public static DateTimeOffset? CalculateClosestStart(DateTimeOffset time, TimeWindowFilterSettings settings) + { + CalculateSurroundingOccurrences(time, settings, out DateTimeOffset? prevOccurrence, out DateTimeOffset? nextOccurrence); + + if (time < settings.Start.Value) + { + return nextOccurrence.Value; + } + + if (prevOccurrence != null) + { + bool isWithinPreviousTimeWindow = + time < prevOccurrence.Value + (settings.End.Value - settings.Start.Value); + + if (isWithinPreviousTimeWindow) + { + return prevOccurrence.Value; + } + + if (nextOccurrence != null) + { + return nextOccurrence.Value; + } + } + + return null; + } + + /// + /// Calculates the closest previous recurrence occurrence (if any) before the given time and the next occurrence (if any) after it. + /// A datetime. + /// The settings of time window filter. + /// The closest previous occurrence. Note that prev occurrence can be null even if the time is past the start date, because the recurrence range may have surpassed its end. + /// The next occurrence. Note that next occurrence can be null even if the prev occurrence is not null, because the recurrence range may have reached its end. + /// + private static void CalculateSurroundingOccurrences(DateTimeOffset time, TimeWindowFilterSettings settings, out DateTimeOffset? prevOccurrence, out DateTimeOffset? nextOccurrence) + { + Debug.Assert(settings != null); + Debug.Assert(settings.Start != null); + Debug.Assert(settings.Recurrence != null); + Debug.Assert(settings.Recurrence.Pattern != null); + Debug.Assert(settings.Recurrence.Range != null); + + prevOccurrence = null; + + nextOccurrence = null; + + if (time < settings.Start.Value) + { + nextOccurrence = settings.Start.Value; + + return; + } + + if (TryFindPreviousOccurrence(time, settings, out DateTimeOffset prev, out int numberOfOccurrences)) + { + prevOccurrence = prev; + + RecurrencePattern pattern = settings.Recurrence.Pattern; + + if (pattern.Type == RecurrencePatternType.Daily) + { + nextOccurrence = prev.AddDays(pattern.Interval); + } + + if (pattern.Type == RecurrencePatternType.Weekly) + { + nextOccurrence = CalculateWeeklyNextOccurrence(prev, settings); + } + + RecurrenceRange range = settings.Recurrence.Range; + + if (range.Type == RecurrenceRangeType.EndDate) + { + if (nextOccurrence > range.EndDate) + { + nextOccurrence = null; + } + } + + if (range.Type == RecurrenceRangeType.Numbered) + { + if (numberOfOccurrences >= range.NumberOfOccurrences) + { + nextOccurrence = null; + } + } + } + } + + /// + /// Finds the closest previous recurrence occurrence before the given time according to the recurrence pattern. + /// The given time should be later than the recurrence start. + /// A return value indicates whether any previous occurrence can be found. + /// A datetime. + /// The settings of time window filter. + /// The closest previous occurrence. + /// The number of occurrences between the time and the recurrence start. + /// True if the closest previous occurrence is within the recurrence range, false otherwise. + /// + private static bool TryFindPreviousOccurrence(DateTimeOffset time, TimeWindowFilterSettings settings, out DateTimeOffset previousOccurrence, out int numberOfOccurrences) + { + Debug.Assert(settings.Start.Value <= time); + + previousOccurrence = DateTimeOffset.MinValue; + + numberOfOccurrences = 0; + + RecurrencePattern pattern = settings.Recurrence.Pattern; + + if (pattern.Type == RecurrencePatternType.Daily) + { + FindDailyPreviousOccurrence(time, settings, out previousOccurrence, out numberOfOccurrences); + } + + if (pattern.Type == RecurrencePatternType.Weekly) + { + FindWeeklyPreviousOccurrence(time, settings, out previousOccurrence, out numberOfOccurrences); + } + + RecurrenceRange range = settings.Recurrence.Range; + + if (range.Type == RecurrenceRangeType.EndDate) + { + return previousOccurrence <= range.EndDate; + } + + if (range.Type == RecurrenceRangeType.Numbered) + { + return numberOfOccurrences <= range.NumberOfOccurrences; + } + + return true; + } + + /// + /// Finds the closest previous recurrence occurrence before the given time according to the "Daily" recurrence pattern. + /// The given time should be later than the recurrence start. + /// A datetime. + /// The settings of time window filter. + /// The closest previous occurrence. + /// The number of occurrences between the time and the recurrence start. + /// + private static void FindDailyPreviousOccurrence(DateTimeOffset time, TimeWindowFilterSettings settings, out DateTimeOffset previousOccurrence, out int numberOfOccurrences) + { + Debug.Assert(settings.Start.Value <= time); + + RecurrencePattern pattern = settings.Recurrence.Pattern; + + DateTimeOffset start = settings.Start.Value; + + int interval = pattern.Interval; + + TimeSpan timeGap = time - start; + + // + // netstandard2.0 does not support '/' operator for TimeSpan. After we stop supporting netstandard2.0, we can remove .TotalSeconds. + int numberOfInterval = (int)Math.Floor(timeGap.TotalSeconds / TimeSpan.FromDays(interval).TotalSeconds); + + previousOccurrence = start.AddDays(numberOfInterval * interval); + + numberOfOccurrences = numberOfInterval + 1; + } + + /// + /// Finds the closest previous recurrence occurrence before the given time according to the "Weekly" recurrence pattern. + /// The given time should be later than the recurrence start. + /// A datetime. + /// The settings of time window filter. + /// The closest previous occurrence. + /// The number of occurrences between the time and the recurrence start. + /// + private static void FindWeeklyPreviousOccurrence(DateTimeOffset time, TimeWindowFilterSettings settings, out DateTimeOffset previousOccurrence, out int numberOfOccurrences) + { + Debug.Assert(settings.Start.Value <= time); + + RecurrencePattern pattern = settings.Recurrence.Pattern; + + DateTimeOffset start = settings.Start.Value; + + int interval = pattern.Interval; + + DateTimeOffset firstDayOfStartWeek = start.AddDays( + -CalculateWeeklyDayOffset(start.DayOfWeek, pattern.FirstDayOfWeek)); + + // + // netstandard2.0 does not support '/' operator for TimeSpan. After we stop supporting netstandard2.0, we can remove .TotalSeconds. + int numberOfInterval = (int)Math.Floor((time - firstDayOfStartWeek).TotalSeconds / TimeSpan.FromDays(interval * DaysPerWeek).TotalSeconds); + + DateTimeOffset firstDayOfMostRecentOccurringWeek = firstDayOfStartWeek.AddDays(numberOfInterval * (interval * DaysPerWeek)); + + List sortedDaysOfWeek = SortDaysOfWeek(pattern.DaysOfWeek, pattern.FirstDayOfWeek); + + // + // Subtract the days before the start in the first week. + numberOfOccurrences = numberOfInterval * sortedDaysOfWeek.Count - sortedDaysOfWeek.IndexOf(start.DayOfWeek); + + // + // The current time is not within the most recent occurring week. + if (time - firstDayOfMostRecentOccurringWeek > TimeSpan.FromDays(DaysPerWeek)) + { + numberOfOccurrences += sortedDaysOfWeek.Count; + + // + // day with max offset in the most recent occurring week + previousOccurrence = firstDayOfMostRecentOccurringWeek.AddDays( + CalculateWeeklyDayOffset(sortedDaysOfWeek.Last(), pattern.FirstDayOfWeek)); + + return; + } + + // + // day with the min offset in the most recent occurring week + DateTimeOffset dayWithMinOffset = firstDayOfMostRecentOccurringWeek.AddDays( + CalculateWeeklyDayOffset(sortedDaysOfWeek.First(), pattern.FirstDayOfWeek)); + + if (dayWithMinOffset < start) + { + numberOfOccurrences = 0; + + dayWithMinOffset = start; + } + + if (time >= dayWithMinOffset) + { + previousOccurrence = dayWithMinOffset; + + numberOfOccurrences++; + + // + // Find the day with the max offset that is less than the current time. + for (int i = sortedDaysOfWeek.IndexOf(dayWithMinOffset.DayOfWeek) + 1; i < sortedDaysOfWeek.Count; i++) + { + DateTimeOffset dayOfWeek = firstDayOfMostRecentOccurringWeek.AddDays( + CalculateWeeklyDayOffset(sortedDaysOfWeek[i], pattern.FirstDayOfWeek)); + + if (time < dayOfWeek) + { + break; + } + + previousOccurrence = dayOfWeek; + + numberOfOccurrences++; + } + } + else + { + // + // the previous occurring week + DateTimeOffset firstDayOfPreviousOccurringWeek = firstDayOfMostRecentOccurringWeek.AddDays(-interval * DaysPerWeek); + + // + // day with max offset in the last occurring week + previousOccurrence = firstDayOfPreviousOccurringWeek.AddDays( + CalculateWeeklyDayOffset(sortedDaysOfWeek.Last(), pattern.FirstDayOfWeek)); + } + } + + /// + /// Finds the next recurrence occurrence after the provided previous occurrence according to the "Weekly" recurrence pattern. + /// The previous occurrence. + /// The settings of time window filter. + /// + private static DateTimeOffset CalculateWeeklyNextOccurrence(DateTimeOffset previousOccurrence, TimeWindowFilterSettings settings) + { + RecurrencePattern pattern = settings.Recurrence.Pattern; + + List sortedDaysOfWeek = SortDaysOfWeek(pattern.DaysOfWeek, pattern.FirstDayOfWeek); + + int i = sortedDaysOfWeek.IndexOf(previousOccurrence.DayOfWeek) + 1; + + if (i < sortedDaysOfWeek.Count()) + { + return previousOccurrence.AddDays( + CalculateWeeklyDayOffset(sortedDaysOfWeek[i], previousOccurrence.DayOfWeek)); + } + + return previousOccurrence.AddDays( + pattern.Interval * DaysPerWeek - CalculateWeeklyDayOffset(previousOccurrence.DayOfWeek, sortedDaysOfWeek.First())); + } + + /// + /// Calculates the offset in days between two given days of the week. + /// A day of week. + /// A day of week. + /// The number of days to be added to day2 to reach day1 + /// + private static int CalculateWeeklyDayOffset(DayOfWeek day1, DayOfWeek day2) + { + return ((int)day1 - (int)day2 + DaysPerWeek) % DaysPerWeek; + } + + + /// + /// Sorts a collection of days of week based on their offsets from a specified first day of week. + /// A collection of days of week. + /// The first day of week. + /// The sorted days of week. + /// + private static List SortDaysOfWeek(IEnumerable daysOfWeek, DayOfWeek firstDayOfWeek) + { + List result = daysOfWeek.ToList(); + + result.Sort((x, y) => + CalculateWeeklyDayOffset(x, firstDayOfWeek) + .CompareTo( + CalculateWeeklyDayOffset(y, firstDayOfWeek))); + + return result; + } + } +} diff --git a/src/Microsoft.FeatureManagement/FeatureFilters/Recurrence/RecurrencePattern.cs b/src/Microsoft.FeatureManagement/FeatureFilters/Recurrence/RecurrencePattern.cs new file mode 100644 index 00000000..b750a99a --- /dev/null +++ b/src/Microsoft.FeatureManagement/FeatureFilters/Recurrence/RecurrencePattern.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using System; +using System.Collections.Generic; + +namespace Microsoft.FeatureManagement.FeatureFilters +{ + /// + /// The recurrence pattern describes the frequency by which the time window repeats. + /// + public class RecurrencePattern + { + /// + /// The recurrence pattern type. + /// + public RecurrencePatternType Type { get; set; } + + /// + /// The number of units between occurrences, where units can be in days or weeks, depending on the pattern type. + /// + public int Interval { get; set; } = 1; + + /// + /// The days of the week on which the time window occurs. This property is only applicable for weekly pattern. + /// + public IEnumerable DaysOfWeek { get; set; } + + /// + /// The first day of the week. This property is only applicable for weekly pattern. + /// + public DayOfWeek FirstDayOfWeek { get; set; } + } +} diff --git a/src/Microsoft.FeatureManagement/FeatureFilters/Recurrence/RecurrencePatternType.cs b/src/Microsoft.FeatureManagement/FeatureFilters/Recurrence/RecurrencePatternType.cs new file mode 100644 index 00000000..89142f37 --- /dev/null +++ b/src/Microsoft.FeatureManagement/FeatureFilters/Recurrence/RecurrencePatternType.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +namespace Microsoft.FeatureManagement.FeatureFilters +{ + /// + /// The type of specifying the frequency by which the time window repeats. + /// + public enum RecurrencePatternType + { + /// + /// The pattern where the time window will repeat based on the number of days specified by interval between occurrences. + /// + Daily, + + /// + /// The pattern where the time window will repeat on the same day or days of the week, based on the number of weeks between each set of occurrences. + /// + Weekly + } +} diff --git a/src/Microsoft.FeatureManagement/FeatureFilters/Recurrence/RecurrenceRange.cs b/src/Microsoft.FeatureManagement/FeatureFilters/Recurrence/RecurrenceRange.cs new file mode 100644 index 00000000..ba852a0e --- /dev/null +++ b/src/Microsoft.FeatureManagement/FeatureFilters/Recurrence/RecurrenceRange.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using System; + +namespace Microsoft.FeatureManagement.FeatureFilters +{ + /// + /// The recurrence range describes a date range over which the time window repeats. + /// + public class RecurrenceRange + { + /// + /// The recurrence range type. + /// + public RecurrenceRangeType Type { get; set; } + + /// + /// The date to stop applying the recurrence pattern. + /// + public DateTimeOffset EndDate { get; set; } = DateTimeOffset.MaxValue; + + /// + /// The number of times to repeat the time window. + /// + public int NumberOfOccurrences { get; set; } = int.MaxValue; + } +} diff --git a/src/Microsoft.FeatureManagement/FeatureFilters/Recurrence/RecurrenceRangeType.cs b/src/Microsoft.FeatureManagement/FeatureFilters/Recurrence/RecurrenceRangeType.cs new file mode 100644 index 00000000..942aa48b --- /dev/null +++ b/src/Microsoft.FeatureManagement/FeatureFilters/Recurrence/RecurrenceRangeType.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +namespace Microsoft.FeatureManagement.FeatureFilters +{ + /// + /// The type of specifying the date range over which the time window repeats. + /// + public enum RecurrenceRangeType + { + /// + /// The time window repeats on all the days that fit the corresponding . + /// + NoEnd, + + /// + /// The time window repeats on all the days that fit the corresponding before or on the end date specified in EndDate of . + /// + EndDate, + + /// + /// The time window repeats for the number specified in the NumberOfOccurrences of that fit based on the . + /// + Numbered + } +} diff --git a/src/Microsoft.FeatureManagement/FeatureFilters/Recurrence/RecurrenceValidator.cs b/src/Microsoft.FeatureManagement/FeatureFilters/Recurrence/RecurrenceValidator.cs new file mode 100644 index 00000000..992bea72 --- /dev/null +++ b/src/Microsoft.FeatureManagement/FeatureFilters/Recurrence/RecurrenceValidator.cs @@ -0,0 +1,431 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; + +namespace Microsoft.FeatureManagement.FeatureFilters +{ + static class RecurrenceValidator + { + const int DaysPerWeek = 7; + + // + // Error Message + const string ValueOutOfRange = "The value is out of the accepted range."; + const string UnrecognizableValue = "The value is unrecognizable."; + const string RequiredParameter = "Value cannot be null or empty."; + const string StartNotMatched = "Start date is not a valid first occurrence."; + const string TimeWindowDurationOutOfRange = "Time window duration cannot be longer than how frequently it occurs or be longer than 10 years."; + + /// + /// Performs validation of time window settings. + /// The settings of time window filter. + /// The name of the invalid setting, if any. + /// The reason that the setting is invalid. + /// True if the provided settings are valid. False if the provided settings are invalid. + /// + public static bool TryValidateSettings(TimeWindowFilterSettings settings, out string paramName, out string reason) + { + if (settings == null) + { + throw new ArgumentNullException(nameof(settings)); + } + + if (settings.Recurrence != null) + { + return TryValidateRecurrenceRequiredParameter(settings, out paramName, out reason) && + TryValidateRecurrencePattern(settings, out paramName, out reason) && + TryValidateRecurrenceRange(settings, out paramName, out reason); + } + + paramName = null; + + reason = null; + + return true; + } + + private static bool TryValidateRecurrenceRequiredParameter(TimeWindowFilterSettings settings, out string paramName, out string reason) + { + Debug.Assert(settings != null); + Debug.Assert(settings.Recurrence != null); + + if (settings.Start == null) + { + paramName = nameof(settings.Start); + + reason = RequiredParameter; + + return false; + } + + if (settings.End == null) + { + paramName = nameof(settings.End); + + reason = RequiredParameter; + + return false; + } + + Recurrence recurrence = settings.Recurrence; + + if (recurrence.Pattern == null) + { + paramName = $"{nameof(settings.Recurrence)}.{nameof(recurrence.Pattern)}"; + + reason = RequiredParameter; + + return false; + } + + if (recurrence.Range == null) + { + paramName = $"{nameof(settings.Recurrence)}.{nameof(recurrence.Range)}"; + + reason = RequiredParameter; + + return false; + } + + if (settings.End.Value <= settings.Start.Value) + { + paramName = nameof(settings.End); + + reason = ValueOutOfRange; + + return false; + } + + if (settings.End.Value - settings.Start.Value >= TimeSpan.FromDays(3650)) + { + paramName = nameof(settings.End); + + reason = TimeWindowDurationOutOfRange; + + return false; + } + + paramName = null; + + reason = null; + + return true; + } + + private static bool TryValidateRecurrencePattern(TimeWindowFilterSettings settings, out string paramName, out string reason) + { + Debug.Assert(settings != null); + Debug.Assert(settings.Start != null); + Debug.Assert(settings.End != null); + Debug.Assert(settings.Recurrence != null); + Debug.Assert(settings.Recurrence.Pattern != null); + + if (!TryValidateInterval(settings, out paramName, out reason)) + { + return false; + } + + switch (settings.Recurrence.Pattern.Type) + { + case RecurrencePatternType.Daily: + return TryValidateDailyRecurrencePattern(settings, out paramName, out reason); + + case RecurrencePatternType.Weekly: + return TryValidateWeeklyRecurrencePattern(settings, out paramName, out reason); + + default: + paramName = $"{nameof(settings.Recurrence)}.{nameof(settings.Recurrence.Pattern)}.{nameof(settings.Recurrence.Pattern.Type)}"; + + reason = UnrecognizableValue; + + return false; + } + } + + private static bool TryValidateDailyRecurrencePattern(TimeWindowFilterSettings settings, out string paramName, out string reason) + { + Debug.Assert(settings.Recurrence.Pattern.Interval > 0); + + // + // No required parameter for "Daily" pattern + // "Start" is always a valid first occurrence for "Daily" pattern + + TimeSpan intervalDuration = TimeSpan.FromDays(settings.Recurrence.Pattern.Interval); + + TimeSpan timeWindowDuration = settings.End.Value - settings.Start.Value; + + // + // Time window duration must be shorter than how frequently it occurs + if (timeWindowDuration > intervalDuration) + { + paramName = $"{nameof(settings.End)}"; + + reason = TimeWindowDurationOutOfRange; + + return false; + } + + paramName = null; + + reason = null; + + return true; + } + + private static bool TryValidateWeeklyRecurrencePattern(TimeWindowFilterSettings settings, out string paramName, out string reason) + { + RecurrencePattern pattern = settings.Recurrence.Pattern; + + Debug.Assert(pattern.Interval > 0); + + // + // Required parameters + if (!TryValidateDaysOfWeek(settings, out paramName, out reason)) + { + return false; + } + + TimeSpan intervalDuration = TimeSpan.FromDays(pattern.Interval * DaysPerWeek); + + TimeSpan timeWindowDuration = settings.End.Value - settings.Start.Value; + + // + // Time window duration must be shorter than how frequently it occurs + if (timeWindowDuration > intervalDuration || + !IsDurationCompliantWithDaysOfWeek(timeWindowDuration, pattern.Interval, pattern.DaysOfWeek, pattern.FirstDayOfWeek)) + { + paramName = $"{nameof(settings.End)}"; + + reason = TimeWindowDurationOutOfRange; + + return false; + } + + // + // Check whether "Start" is a valid first occurrence + DateTimeOffset start = settings.Start.Value; + + if (!pattern.DaysOfWeek.Any(day => + day == start.DayOfWeek)) + { + paramName = nameof(settings.Start); + + reason = StartNotMatched; + + return false; + } + + return true; + } + + private static bool TryValidateRecurrenceRange(TimeWindowFilterSettings settings, out string paramName, out string reason) + { + Debug.Assert(settings != null); + Debug.Assert(settings.Start != null); + Debug.Assert(settings.Recurrence != null); + Debug.Assert(settings.Recurrence.Range != null); + + switch (settings.Recurrence.Range.Type) + { + case RecurrenceRangeType.NoEnd: + paramName = null; + + reason = null; + + return true; + + case RecurrenceRangeType.EndDate: + return TryValidateEndDate(settings, out paramName, out reason); + + case RecurrenceRangeType.Numbered: + return TryValidateNumberOfOccurrences(settings, out paramName, out reason); + + default: + paramName = $"{nameof(settings.Recurrence)}.{nameof(settings.Recurrence.Range)}.{nameof(settings.Recurrence.Range.Type)}"; + + reason = UnrecognizableValue; + + return false; + } + } + + private static bool TryValidateInterval(TimeWindowFilterSettings settings, out string paramName, out string reason) + { + paramName = $"{nameof(settings.Recurrence)}.{nameof(settings.Recurrence.Pattern)}.{nameof(settings.Recurrence.Pattern.Interval)}"; + + if (settings.Recurrence.Pattern.Interval <= 0) + { + reason = ValueOutOfRange; + + return false; + } + + reason = null; + + return true; + } + + private static bool TryValidateDaysOfWeek(TimeWindowFilterSettings settings, out string paramName, out string reason) + { + paramName = $"{nameof(settings.Recurrence)}.{nameof(settings.Recurrence.Pattern)}.{nameof(settings.Recurrence.Pattern.DaysOfWeek)}"; + + if (settings.Recurrence.Pattern.DaysOfWeek == null || !settings.Recurrence.Pattern.DaysOfWeek.Any()) + { + reason = RequiredParameter; + + return false; + } + + reason = null; + + return true; + } + + private static bool TryValidateEndDate(TimeWindowFilterSettings settings, out string paramName, out string reason) + { + paramName = $"{nameof(settings.Recurrence)}.{nameof(settings.Recurrence.Range)}.{nameof(settings.Recurrence.Range.EndDate)}"; + + if (settings.Start == null) + { + paramName = nameof(settings.Start); + + reason = RequiredParameter; + + return false; + } + + DateTimeOffset start = settings.Start.Value; + + DateTimeOffset endDate = settings.Recurrence.Range.EndDate; + + if (endDate < start) + { + reason = ValueOutOfRange; + + return false; + } + + reason = null; + + return true; + } + + private static bool TryValidateNumberOfOccurrences(TimeWindowFilterSettings settings, out string paramName, out string reason) + { + paramName = $"{nameof(settings.Recurrence)}.{nameof(settings.Recurrence.Range)}.{nameof(settings.Recurrence.Range.NumberOfOccurrences)}"; + + if (settings.Recurrence.Range.NumberOfOccurrences < 1) + { + reason = ValueOutOfRange; + + return false; + } + + reason = null; + + return true; + } + + /// + /// Checks whether the duration is shorter than the minimum gap between recurrence of days of week. + /// + /// The time span of the duration. + /// The recurrence interval. + /// The days of the week when the recurrence will occur. + /// The first day of the week. + /// True if the duration is compliant with days of week, false otherwise. + private static bool IsDurationCompliantWithDaysOfWeek(TimeSpan duration, int interval, IEnumerable daysOfWeek, DayOfWeek firstDayOfWeek) + { + Debug.Assert(interval > 0); + + if (daysOfWeek.Count() == 1) + { + return true; + } + + DateTime firstDayOfThisWeek = DateTime.Today.AddDays( + DaysPerWeek - CalculateWeeklyDayOffset(DateTime.Today.DayOfWeek, firstDayOfWeek)); + + List sortedDaysOfWeek = SortDaysOfWeek(daysOfWeek, firstDayOfWeek); + + DateTime prev = DateTime.MinValue; + + TimeSpan minGap = TimeSpan.FromDays(DaysPerWeek); + + foreach (DayOfWeek dayOfWeek in sortedDaysOfWeek) + { + if (prev == DateTime.MinValue) + { + prev = firstDayOfThisWeek.AddDays( + CalculateWeeklyDayOffset(dayOfWeek, firstDayOfWeek)); + } + else + { + DateTime date = firstDayOfThisWeek.AddDays( + CalculateWeeklyDayOffset(dayOfWeek, firstDayOfWeek)); + + TimeSpan gap = date - prev; + + if (gap < minGap) + { + minGap = gap; + } + + prev = date; + } + } + + // + // It may across weeks. Check the next week if the interval is one week. + if (interval == 1) + { + DateTime firstDayOfNextWeek = firstDayOfThisWeek.AddDays(DaysPerWeek); + + DateTime firstOccurrenceInNextWeek = firstDayOfNextWeek.AddDays( + CalculateWeeklyDayOffset(sortedDaysOfWeek.First(), firstDayOfWeek)); + + TimeSpan gap = firstOccurrenceInNextWeek - prev; + + if (gap < minGap) + { + minGap = gap; + } + } + + return minGap >= duration; + } + + /// + /// Calculates the offset in days between two given days of the week. + /// A day of week. + /// A day of week. + /// The number of days to be added to day2 to reach day1 + /// + private static int CalculateWeeklyDayOffset(DayOfWeek day1, DayOfWeek day2) + { + return ((int)day1 - (int)day2 + DaysPerWeek) % DaysPerWeek; + } + + /// + /// Sorts a collection of days of week based on their offsets from a specified first day of week. + /// A collection of days of week. + /// The first day of week. + /// The sorted days of week. + /// + private static List SortDaysOfWeek(IEnumerable daysOfWeek, DayOfWeek firstDayOfWeek) + { + List result = daysOfWeek.ToList(); + + result.Sort((x, y) => + CalculateWeeklyDayOffset(x, firstDayOfWeek) + .CompareTo( + CalculateWeeklyDayOffset(y, firstDayOfWeek))); + + return result; + } + } +} diff --git a/src/Microsoft.FeatureManagement/FeatureFilters/TimeWindowFilter.cs b/src/Microsoft.FeatureManagement/FeatureFilters/TimeWindowFilter.cs index 9993cdc9..121bb4cf 100644 --- a/src/Microsoft.FeatureManagement/FeatureFilters/TimeWindowFilter.cs +++ b/src/Microsoft.FeatureManagement/FeatureFilters/TimeWindowFilter.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // +using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using System; @@ -10,10 +11,14 @@ namespace Microsoft.FeatureManagement.FeatureFilters { /// /// A feature filter that can be used to activate a feature based on a time window. + /// The time window can be configured to recur periodically. /// [FilterAlias(Alias)] public class TimeWindowFilter : IFeatureFilter, IFilterParametersBinder { + private readonly TimeSpan CacheSlidingExpiration = TimeSpan.FromMinutes(5); + private readonly TimeSpan CacheAbsoluteExpirationRelativeToNow = TimeSpan.FromDays(1); + private const string Alias = "Microsoft.TimeWindow"; private readonly ILogger _logger; @@ -26,6 +31,16 @@ public TimeWindowFilter(ILoggerFactory loggerFactory = null) _logger = loggerFactory?.CreateLogger(); } + /// + /// The application memory cache to store the start time of the closest active time window. By caching this time, the time window can minimize redundant computations when evaluating recurrence. + /// + public IMemoryCache Cache { get; init; } + + /// + /// This property allows the time window filter in our test suite to use simulated time. + /// + internal ISystemClock SystemClock { get; init; } + /// /// Binds configuration representing filter parameters to . /// @@ -33,11 +48,18 @@ public TimeWindowFilter(ILoggerFactory loggerFactory = null) /// that can later be used in feature evaluation. public object BindParameters(IConfiguration filterParameters) { - return filterParameters.Get() ?? new TimeWindowFilterSettings(); + var settings = filterParameters.Get() ?? new TimeWindowFilterSettings(); + + if (!RecurrenceValidator.TryValidateSettings(settings, out string paramName, out string reason)) + { + throw new ArgumentException(reason, paramName); + } + + return settings; } /// - /// Evaluates whether a feature is enabled based on a configurable time window. + /// Evaluates whether a feature is enabled based on the specified in the configuration. /// /// The feature evaluation context. /// True if the feature is enabled, false otherwise. @@ -52,7 +74,7 @@ public Task EvaluateAsync(FeatureFilterEvaluationContext context) // Check if prebound settings available, otherwise bind from parameters. TimeWindowFilterSettings settings = (TimeWindowFilterSettings)context.Settings ?? (TimeWindowFilterSettings)BindParameters(context.Parameters); - DateTimeOffset now = DateTimeOffset.UtcNow; + DateTimeOffset now = SystemClock?.UtcNow ?? DateTimeOffset.UtcNow; if (!settings.Start.HasValue && !settings.End.HasValue) { @@ -61,7 +83,67 @@ public Task EvaluateAsync(FeatureFilterEvaluationContext context) return Task.FromResult(false); } - return Task.FromResult((!settings.Start.HasValue || now >= settings.Start.Value) && (!settings.End.HasValue || now < settings.End.Value)); + // + // Hit the first occurrence of the time window + if ((!settings.Start.HasValue || now >= settings.Start.Value) && (!settings.End.HasValue || now < settings.End.Value)) + { + return Task.FromResult(true); + } + + if (settings.Recurrence != null) + { + // + // The reference of the object will be used for cache key. + // If there is no pre-bounded settings attached to the context, there will be no cached filter settings and each call will have a unique settings object. + // In this case, the cache for recurrence settings won't work. + if (Cache == null || context.Settings == null) + { + return Task.FromResult(RecurrenceEvaluator.IsMatch(now, settings)); + } + + // + // The start time of the closest active time window. It could be null if the recurrence range surpasses its end. + DateTimeOffset? closestStart; + + TimeSpan activeDuration = settings.End.Value - settings.Start.Value; + + // + // Recalculate the closest start if not yet calculated, + // Or if we have passed the cached time window. + if (!Cache.TryGetValue(settings, out closestStart) || + (closestStart.HasValue && now >= closestStart.Value + activeDuration)) + { + closestStart = ReloadClosestStart(settings); + } + + if (!closestStart.HasValue || now < closestStart.Value) + { + return Task.FromResult(false); + } + + return Task.FromResult(now < closestStart.Value + activeDuration); + } + + return Task.FromResult(false); + } + + private DateTimeOffset? ReloadClosestStart(TimeWindowFilterSettings settings) + { + DateTimeOffset now = SystemClock?.UtcNow ?? DateTimeOffset.UtcNow; + + DateTimeOffset? closestStart = RecurrenceEvaluator.CalculateClosestStart(now, settings); + + Cache.Set( + settings, + closestStart, + new MemoryCacheEntryOptions + { + SlidingExpiration = CacheSlidingExpiration, + AbsoluteExpirationRelativeToNow = CacheAbsoluteExpirationRelativeToNow, + Size = 1 + }); + + return closestStart; } } } diff --git a/src/Microsoft.FeatureManagement/FeatureFilters/TimeWindowFilterSettings.cs b/src/Microsoft.FeatureManagement/FeatureFilters/TimeWindowFilterSettings.cs index 41f87cf3..6a0bb0d4 100644 --- a/src/Microsoft.FeatureManagement/FeatureFilters/TimeWindowFilterSettings.cs +++ b/src/Microsoft.FeatureManagement/FeatureFilters/TimeWindowFilterSettings.cs @@ -14,12 +14,18 @@ public class TimeWindowFilterSettings /// An optional start time used to determine when a feature configured to use the feature filter should be enabled. /// If no start time is specified the time window is considered to have already started. /// - public DateTimeOffset? Start { get; set; } // E.g. "Wed, 01 May 2019 22:59:30 GMT" + public DateTimeOffset? Start { get; set; } /// /// An optional end time used to determine when a feature configured to use the feature filter should be enabled. /// If no end time is specified the time window is considered to never end. /// - public DateTimeOffset? End { get; set; } // E.g. "Wed, 01 May 2019 23:00:00 GMT" + public DateTimeOffset? End { get; set; } + + /// + /// Add-on recurrence rule allows the time window defined by Start and End to recur. + /// The rule specifies both how often the time window repeats and for how long. + /// + public Recurrence Recurrence { get; set; } } } diff --git a/src/Microsoft.FeatureManagement/FeatureManagementBuilder.cs b/src/Microsoft.FeatureManagement/FeatureManagementBuilder.cs index 031d6f42..1d1e699b 100644 --- a/src/Microsoft.FeatureManagement/FeatureManagementBuilder.cs +++ b/src/Microsoft.FeatureManagement/FeatureManagementBuilder.cs @@ -52,6 +52,38 @@ public IFeatureManagementBuilder AddFeatureFilter() where T : IFeatureFilterM return this; } + public IFeatureManagementBuilder AddFeatureFilter(Func implementationFactory) where T : IFeatureFilterMetadata + { + Type serviceType = typeof(IFeatureFilterMetadata); + + Type implementationType = typeof(T); + + IEnumerable featureFilterImplementations = implementationType.GetInterfaces() + .Where(i => i == typeof(IFeatureFilter) || + (i.IsGenericType && i.GetGenericTypeDefinition().IsAssignableFrom(typeof(IContextualFeatureFilter<>)))); + + if (featureFilterImplementations.Count() > 1) + { + throw new ArgumentException($"A single feature filter cannot implement more than one feature filter interface.", nameof(T)); + } + + if (!Services.Any(descriptor => descriptor.ServiceType == serviceType && descriptor.ImplementationType == implementationType)) + { + // + // Register the feature filter with the same lifetime as the feature manager + if (Services.Any(descriptor => descriptor.ServiceType == typeof(IFeatureManager) && descriptor.Lifetime == ServiceLifetime.Scoped)) + { + Services.AddScoped(serviceType, implementationFactory); + } + else + { + Services.AddSingleton(serviceType, implementationFactory); + } + } + + return this; + } + public IFeatureManagementBuilder AddSessionManager() where T : ISessionManager { // diff --git a/src/Microsoft.FeatureManagement/ServiceCollectionExtensions.cs b/src/Microsoft.FeatureManagement/ServiceCollectionExtensions.cs index 02225d2c..b0a9bdd6 100644 --- a/src/Microsoft.FeatureManagement/ServiceCollectionExtensions.cs +++ b/src/Microsoft.FeatureManagement/ServiceCollectionExtensions.cs @@ -42,15 +42,16 @@ public static IFeatureManagementBuilder AddFeatureManagement(this IServiceCollec // Add required services services.TryAddSingleton(); - services.AddSingleton(sp => new FeatureManager( - sp.GetRequiredService(), - sp.GetRequiredService>().Value) - { - FeatureFilters = sp.GetRequiredService>(), - SessionManagers = sp.GetRequiredService>(), - Cache = sp.GetRequiredService(), - Logger = sp.GetRequiredService().CreateLogger() - }); + services.AddSingleton(sp => + new FeatureManager( + sp.GetRequiredService(), + sp.GetRequiredService>().Value) + { + FeatureFilters = sp.GetRequiredService>(), + SessionManagers = sp.GetRequiredService>(), + Cache = sp.GetRequiredService(), + Logger = sp.GetRequiredService().CreateLogger() + }); services.AddScoped(); @@ -60,7 +61,11 @@ public static IFeatureManagementBuilder AddFeatureManagement(this IServiceCollec // Add built-in feature filters builder.AddFeatureFilter(); - builder.AddFeatureFilter(); + builder.AddFeatureFilter(sp => + new TimeWindowFilter() + { + Cache = sp.GetRequiredService() + }); builder.AddFeatureFilter(); @@ -114,15 +119,16 @@ public static IFeatureManagementBuilder AddScopedFeatureManagement(this IService // Add required services services.TryAddSingleton(); - services.AddScoped(sp => new FeatureManager( - sp.GetRequiredService(), - sp.GetRequiredService>().Value) - { - FeatureFilters = sp.GetRequiredService>(), - SessionManagers = sp.GetRequiredService>(), - Cache = sp.GetRequiredService(), - Logger = sp.GetRequiredService().CreateLogger() - }); + services.AddScoped(sp => + new FeatureManager( + sp.GetRequiredService(), + sp.GetRequiredService>().Value) + { + FeatureFilters = sp.GetRequiredService>(), + SessionManagers = sp.GetRequiredService>(), + Cache = sp.GetRequiredService(), + Logger = sp.GetRequiredService().CreateLogger() + }); services.AddScoped(); @@ -132,7 +138,11 @@ public static IFeatureManagementBuilder AddScopedFeatureManagement(this IService // Add built-in feature filters builder.AddFeatureFilter(); - builder.AddFeatureFilter(); + builder.AddFeatureFilter(sp => + new TimeWindowFilter() + { + Cache = sp.GetRequiredService() + }); builder.AddFeatureFilter(); diff --git a/tests/Tests.FeatureManagement/FeatureManagement.cs b/tests/Tests.FeatureManagement/FeatureManagement.cs index 8f965fd9..e5c0ba06 100644 --- a/tests/Tests.FeatureManagement/FeatureManagement.cs +++ b/tests/Tests.FeatureManagement/FeatureManagement.cs @@ -295,6 +295,8 @@ public async Task TimeWindow() const string feature2 = "feature2"; const string feature3 = "feature3"; const string feature4 = "feature4"; + const string feature5 = "feature5"; + const string feature6 = "feature6"; Environment.SetEnvironmentVariable($"FeatureManagement:{feature1}:EnabledFor:0:Name", "TimeWindow"); Environment.SetEnvironmentVariable($"FeatureManagement:{feature1}:EnabledFor:0:Parameters:End", DateTimeOffset.UtcNow.AddDays(1).ToString("r")); @@ -308,7 +310,23 @@ public async Task TimeWindow() Environment.SetEnvironmentVariable($"FeatureManagement:{feature4}:EnabledFor:0:Name", "TimeWindow"); Environment.SetEnvironmentVariable($"FeatureManagement:{feature4}:EnabledFor:0:Parameters:Start", DateTimeOffset.UtcNow.AddDays(1).ToString("r")); - IConfiguration config = new ConfigurationBuilder().AddEnvironmentVariables().Build(); + Environment.SetEnvironmentVariable($"FeatureManagement:{feature5}:EnabledFor:0:Name", "TimeWindow"); + Environment.SetEnvironmentVariable($"FeatureManagement:{feature5}:EnabledFor:0:Parameters:Start", DateTimeOffset.UtcNow.AddDays(-2).ToString("r")); + Environment.SetEnvironmentVariable($"FeatureManagement:{feature5}:EnabledFor:0:Parameters:End", DateTimeOffset.UtcNow.AddDays(-1).ToString("r")); + Environment.SetEnvironmentVariable($"FeatureManagement:{feature5}:EnabledFor:0:Parameters:Recurrence:Pattern:Type", "Daily"); + Environment.SetEnvironmentVariable($"FeatureManagement:{feature5}:EnabledFor:0:Parameters:Recurrence:Range:Type", "NoEnd"); + + Environment.SetEnvironmentVariable($"FeatureManagement:{feature6}:EnabledFor:0:Name", "TimeWindow"); + Environment.SetEnvironmentVariable($"FeatureManagement:{feature6}:EnabledFor:0:Parameters:Start", DateTimeOffset.UtcNow.AddDays(-2).ToString("r")); + Environment.SetEnvironmentVariable($"FeatureManagement:{feature6}:EnabledFor:0:Parameters:End", DateTimeOffset.UtcNow.AddDays(-1).ToString("r")); + Environment.SetEnvironmentVariable($"FeatureManagement:{feature6}:EnabledFor:0:Parameters:Recurrence:Pattern:Type", "Daily"); + Environment.SetEnvironmentVariable($"FeatureManagement:{feature6}:EnabledFor:0:Parameters:Recurrence:Pattern:Interval", "3"); + Environment.SetEnvironmentVariable($"FeatureManagement:{feature6}:EnabledFor:0:Parameters:Recurrence:Range:Type", "NoEnd"); + + IConfiguration config = new ConfigurationBuilder() + .AddEnvironmentVariables() + .AddJsonFile("appsettings.json") + .Build(); var serviceCollection = new ServiceCollection(); @@ -323,6 +341,13 @@ public async Task TimeWindow() Assert.False(await featureManager.IsEnabledAsync(feature2)); Assert.True(await featureManager.IsEnabledAsync(feature3)); Assert.False(await featureManager.IsEnabledAsync(feature4)); + Assert.True(await featureManager.IsEnabledAsync(feature5)); + Assert.False(await featureManager.IsEnabledAsync(feature6)); + + for (int i = 0; i < 10; i++) + { + Assert.True(await featureManager.IsEnabledAsync(Features.RecurringTimeWindowTestFeature)); + } } [Fact] @@ -333,7 +358,7 @@ public async Task Percentage() Environment.SetEnvironmentVariable($"FeatureManagement:{feature}:EnabledFor:0:Name", "Percentage"); Environment.SetEnvironmentVariable($"FeatureManagement:{feature}:EnabledFor:0:Parameters:Value", "50"); - IConfiguration config = new ConfigurationBuilder().AddEnvironmentVariables().Build(); + IConfiguration config = new ConfigurationBuilder().AddEnvironmentVariables().AddJsonFile("appsettings.json").Build(); var serviceCollection = new ServiceCollection(); diff --git a/tests/Tests.FeatureManagement/Features.cs b/tests/Tests.FeatureManagement/Features.cs index b52ec008..4fa4fe91 100644 --- a/tests/Tests.FeatureManagement/Features.cs +++ b/tests/Tests.FeatureManagement/Features.cs @@ -5,6 +5,7 @@ namespace Tests.FeatureManagement { static class Features { + public const string RecurringTimeWindowTestFeature = "RecurringTimeWindowTestFeature"; public const string TargetingTestFeature = "TargetingTestFeature"; public const string TargetingTestFeatureWithExclusion = "TargetingTestFeatureWithExclusion"; public const string OnTestFeature = "OnTestFeature"; diff --git a/tests/Tests.FeatureManagement/OnDemandClock.cs b/tests/Tests.FeatureManagement/OnDemandClock.cs new file mode 100644 index 00000000..c639a3e3 --- /dev/null +++ b/tests/Tests.FeatureManagement/OnDemandClock.cs @@ -0,0 +1,10 @@ +using Microsoft.FeatureManagement.FeatureFilters; +using System; + +namespace Tests.FeatureManagement +{ + class OnDemandClock : ISystemClock + { + public DateTimeOffset UtcNow { get; set; } + } +} diff --git a/tests/Tests.FeatureManagement/RecurrenceEvaluation.cs b/tests/Tests.FeatureManagement/RecurrenceEvaluation.cs new file mode 100644 index 00000000..50c6e449 --- /dev/null +++ b/tests/Tests.FeatureManagement/RecurrenceEvaluation.cs @@ -0,0 +1,1740 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Microsoft.Extensions.Caching.Memory; +using Microsoft.FeatureManagement; +using Microsoft.FeatureManagement.FeatureFilters; +using System; +using System.Collections.Generic; +using System.Linq; +using Xunit; + +namespace Tests.FeatureManagement +{ + class ErrorMessage + { + public const string ValueOutOfRange = "The value is out of the accepted range."; + public const string UnrecognizableValue = "The value is unrecognizable."; + public const string RequiredParameter = "Value cannot be null or empty."; + public const string StartNotMatched = "Start date is not a valid first occurrence."; + public const string TimeWindowDurationOutOfRange = "Time window duration cannot be longer than how frequently it occurs or be longer than 10 years."; + } + + class ParamName + { + public const string Start = "Start"; + public const string End = "End"; + + public const string Pattern = "Recurrence.Pattern"; + public const string PatternType = "Recurrence.Pattern.Type"; + public const string Interval = "Recurrence.Pattern.Interval"; + public const string DaysOfWeek = "Recurrence.Pattern.DaysOfWeek"; + public const string Month = "Recurrence.Pattern.Month"; + public const string DayOfMonth = "Recurrence.Pattern.DayOfMonth"; + + public const string Range = "Recurrence.Range"; + public const string RangeType = "Recurrence.Range.Type"; + public const string NumberOfOccurrences = "Recurrence.Range.NumberOfOccurrences"; + public const string RecurrenceTimeZone = "Recurrence.Range.RecurrenceTimeZone"; + public const string EndDate = "Recurrence.Range.EndDate"; + } + + public class RecurrenceValidatorTest + { + private static void ConsumeValidationTestData(List> testData) + { + foreach ((TimeWindowFilterSettings settings, string paramNameRef, string errorMessageRef) in testData) + { + RecurrenceValidator.TryValidateSettings(settings, out string paramName, out string errorMessage); + + Assert.Equal(paramNameRef, paramName); + Assert.Equal(errorMessageRef, errorMessage); + } + } + + [Fact] + public void GeneralRequiredParameterTest() + { + var testData = new List>() + { + ( new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-25T12:00:00+08:00"), + End = null, + Recurrence = new Recurrence() + }, + ParamName.End, + ErrorMessage.RequiredParameter ), + + ( new TimeWindowFilterSettings() + { + Start = null, + End = DateTimeOffset.Parse("2023-9-25T12:00:00+08:00"), + Recurrence = new Recurrence() + }, + ParamName.Start, + ErrorMessage.RequiredParameter ), + + ( new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-25T00:00:00+08:00"), + End = DateTimeOffset.Parse("2023-9-25T02:00:00+08:00"), + Recurrence = new Recurrence() + { + Pattern = null, + Range = new RecurrenceRange() + } + }, + ParamName.Pattern, + ErrorMessage.RequiredParameter ), + + ( new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-25T00:00:00+08:00"), + End = DateTimeOffset.Parse("2023-9-25T02:00:00+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern(), + Range = null + } + }, + ParamName.Range, + ErrorMessage.RequiredParameter ) + }; + + ConsumeValidationTestData(testData); + } + + [Fact] + public void InvalidValueTest() + { + var testData = new List>() + { + ( new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-1T00:00:00+08:00"), + End = DateTimeOffset.Parse("2023-9-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Interval = 0 // Interval should be larger than 0. + }, + Range = new RecurrenceRange() + } + }, + ParamName.Interval, + ErrorMessage.ValueOutOfRange ), + + ( new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-1T00:00:00+08:00"), + End = DateTimeOffset.Parse("2023-9-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern(), + Range = new RecurrenceRange() + { + Type = RecurrenceRangeType.Numbered, + NumberOfOccurrences = 0 // NumberOfOccurrences should be larger than 0. + } + } + }, + ParamName.NumberOfOccurrences, + ErrorMessage.ValueOutOfRange ), + + ( new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-1T00:00:00+08:00"), + End = DateTimeOffset.Parse("2023-9-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern(), + Range = new RecurrenceRange() + { + Type = RecurrenceRangeType.EndDate, + EndDate = DateTimeOffset.Parse("2023-8-31T23:59:59+08:00") // EndDate is earlier than the Start. + } + } + }, + ParamName.EndDate, + ErrorMessage.ValueOutOfRange ) + }; + + ConsumeValidationTestData(testData); + } + + [Fact] + public void InvalidTimeWindowTest() + { + var testData = new List>() + { + ( new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-25T12:00:00+08:00"), + End = DateTimeOffset.Parse("2023-9-25T12:00:00+08:00"), // End equals to Start. + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern(), + Range = new RecurrenceRange() + } + }, + ParamName.End, + ErrorMessage.ValueOutOfRange ), + + ( new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-25T00:00:00+08:00"), + End = DateTimeOffset.Parse("2023-9-27T00:00:01+08:00"), // The duration of the time window is longer than how frequently it recurs. + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Daily, + Interval = 2 + }, + Range = new RecurrenceRange() + } + }, + ParamName.End, + ErrorMessage.TimeWindowDurationOutOfRange ), + + ( new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-1T00:00:00+08:00"), + End = DateTimeOffset.Parse("2023-9-8T00:00:01+08:00"), // The duration of the time window is longer than how frequently it recurs. + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + Interval = 1, + DaysOfWeek = new List(){ DayOfWeek.Friday } // 2023.9.1 is Friday. + }, + Range = new RecurrenceRange() + } + }, + ParamName.End, + ErrorMessage.TimeWindowDurationOutOfRange ), + + ( new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-3T00:00:00+08:00"), + End = DateTimeOffset.Parse("2023-9-5T00:00:01+08:00"), // The duration of the time window is longer than how frequently it recurs. + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + Interval = 1, + // FirstDayOfWeek is Sunday by default + DaysOfWeek = new List(){ DayOfWeek.Monday, DayOfWeek.Thursday, DayOfWeek.Sunday } + }, + Range = new RecurrenceRange() + } + }, + ParamName.End, + ErrorMessage.TimeWindowDurationOutOfRange ), + + ( new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-2T00:00:00+08:00"), + End = DateTimeOffset.Parse("2023-9-5T00:00:01+08:00"), // The duration of the time window is longer than how frequently it recurs. + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + Interval = 1, + // FirstDayOfWeek is Sunday by default + DaysOfWeek = new List(){ DayOfWeek.Monday, DayOfWeek.Saturday } // The time window duration should be shorter than 2 days because the gap between Saturday in the previous week and Monday in this week is 2 days. + }, + Range = new RecurrenceRange() + } + }, + ParamName.End, + ErrorMessage.TimeWindowDurationOutOfRange ), + + ( new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2024-1-16T00:00:00+08:00"), + End = DateTimeOffset.Parse("2024-1-19T00:00:01+08:00"), // The duration of the time window is longer than how frequently it recurs. + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + Interval = 1, + DaysOfWeek = new List(){ DayOfWeek.Tuesday, DayOfWeek.Saturday } // The time window duration should be shorter than 3 days because the gap between Saturday in the previous week and Tuesday in this week is 3 days. + }, + Range = new RecurrenceRange() + } + }, + ParamName.End, + ErrorMessage.TimeWindowDurationOutOfRange ) + }; + + ConsumeValidationTestData(testData); + } + + [Fact] + public void InvalidTimeWindowAcrossWeeksTest() + { + var settings = new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2024-1-16T00:00:00+08:00"), // Tuesday + End = DateTimeOffset.Parse("2024-1-19T00:00:00+08:00"), // Time window duration is 3 days. + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + Interval = 1, + DaysOfWeek = new List() { DayOfWeek.Tuesday, DayOfWeek.Saturday } // The time window duration should be shorter than 3 days because the gap between Saturday in the previous week and Tuesday in this week is 3 days. + }, + Range = new RecurrenceRange() + } + }; + + // + // The settings is valid. No exception should be thrown. + RecurrenceEvaluator.IsMatch(DateTimeOffset.UtcNow, settings); + + settings = new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2024-1-15T00:00:00+08:00"), // Monday + End = DateTimeOffset.Parse("2024-1-19T00:00:00+08:00"), // Time window duration is 4 days. + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + Interval = 2, // The interval is larger than one week, there is no across-week issue. + FirstDayOfWeek = DayOfWeek.Monday, + DaysOfWeek = new List() { DayOfWeek.Monday, DayOfWeek.Sunday } + }, + Range = new RecurrenceRange() + } + }; + + // + // The settings is valid. No exception should be thrown. + RecurrenceEvaluator.IsMatch(DateTimeOffset.UtcNow, settings); + + settings = new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2024-1-15T00:00:00+08:00"), // Monday + End = DateTimeOffset.Parse("2024-1-19T00:00:00+08:00"), // Time window duration is 4 days. + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + Interval = 1, + FirstDayOfWeek = DayOfWeek.Monday, + DaysOfWeek = new List() { DayOfWeek.Monday, DayOfWeek.Sunday } + }, + Range = new RecurrenceRange() + } + }; + + Assert.False(RecurrenceValidator.TryValidateSettings(settings, out string paramName, out string errorMessage)); + Assert.Equal(ParamName.End, paramName); + Assert.Equal(ErrorMessage.TimeWindowDurationOutOfRange, errorMessage); + } + + [Fact] + public void WeeklyPatternRequiredParameterTest() + { + var testData = new List>() + { + ( new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-1T00:00:00+08:00"), + End = DateTimeOffset.Parse("2023-9-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + DaysOfWeek = Enumerable.Empty() + }, + Range = new RecurrenceRange() + } + }, + ParamName.DaysOfWeek, + ErrorMessage.RequiredParameter ) + }; + + ConsumeValidationTestData(testData); + } + + [Fact] + public void WeeklyPatternStartNotMatchedTest() + { + var testData = new List>() + { + ( new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-1T00:00:00+08:00"), // 2023-9-1 is Friday. Start date is not a valid first occurrence. + End = DateTimeOffset.Parse("2023-9-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + DaysOfWeek = new List{ DayOfWeek.Monday, DayOfWeek.Tuesday, DayOfWeek.Wednesday, DayOfWeek.Thursday, DayOfWeek.Saturday, DayOfWeek.Sunday } + }, + Range = new RecurrenceRange() + } + }, + ParamName.Start, + ErrorMessage.StartNotMatched ) + }; + + ConsumeValidationTestData(testData); + } + } + + public class RecurrenceEvaluatorTest + { + private static void ConsumeEvaluationTestData(List> testData) + { + foreach ((DateTimeOffset time, TimeWindowFilterSettings settings, bool expected) in testData) + { + Assert.Equal(RecurrenceEvaluator.IsMatch(time, settings), expected); + } + } + + private static void ConsumeEvalutationTestData(List> testData) + { + foreach ((DateTimeOffset time, TimeWindowFilterSettings settings, DateTimeOffset? expected) in testData) + { + DateTimeOffset? res = RecurrenceEvaluator.CalculateClosestStart(time, settings); + + Assert.Equal(expected, res); + } + } + + [Fact] + public void MatchDailyRecurrenceTest() + { + var testData = new List>() + { + ( DateTimeOffset.Parse("2023-9-2T00:00:00+08:00"), + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-1T00:00:00+08:00"), + End = DateTimeOffset.Parse("2023-9-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Daily + }, + Range = new RecurrenceRange() + } + }, + true ), + + ( DateTimeOffset.Parse("2023-9-2T00:00:00+08:00"), + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-1T00:00:00+08:00"), + End = DateTimeOffset.Parse("2023-9-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Daily, + Interval = 2 + }, + Range = new RecurrenceRange() + } + }, + false ), + + ( DateTimeOffset.Parse("2023-9-5T00:00:00+08:00"), // Within the recurring time window 2023-9-5T00:00:00+08:00 ~ 2023-9-7T00:00:00+08:00 + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-1T00:00:00+08:00"), + End = DateTimeOffset.Parse("2023-9-3T00:00:00+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Daily, + Interval = 4 + }, + Range = new RecurrenceRange() + } + }, + true ), + + ( DateTimeOffset.Parse("2023-9-6T00:00:00+08:00"), // Within the recurring time window 2023-9-5T00:00:00+08:00 ~ 2023-9-7T00:00:00+08:00 + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-1T00:00:00+08:00"), + End = DateTimeOffset.Parse("2023-9-3T00:00:00+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Daily, + Interval = 4 + }, + Range = new RecurrenceRange() + } + }, + true ), + + ( DateTimeOffset.Parse("2023-9-9T00:00:00+08:00"), // Within the recurring time window 2023-9-9T00:00:00+08:00 ~ 2023-9-11T00:00:00+08:00 + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-1T00:00:00+08:00"), + End = DateTimeOffset.Parse("2023-9-3T00:00:00+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Daily, + Interval = 4 + }, + Range = new RecurrenceRange() + } + }, + true ), + + ( DateTimeOffset.Parse("2023-9-3T00:00:00+08:00"), // Within the recurring time window 2023-9-3T00:00:00+08:00 ~ 2023-9-31T00:00:01+08:00 + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-1T00:00:00+08:00"), + End = DateTimeOffset.Parse("2023-9-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Daily, + Interval = 2 + }, + Range = new RecurrenceRange() + } + }, + true ), + + ( DateTimeOffset.Parse("2023-9-3T00:00:00+08:00"), // The third occurrence + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-1T00:00:00+08:00"), + End = DateTimeOffset.Parse("2023-9-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Daily + }, + Range = new RecurrenceRange() + { + Type = RecurrenceRangeType.Numbered, + NumberOfOccurrences = 2 + } + } + }, + false ), + + ( DateTimeOffset.Parse("2023-9-4T00:00:00+08:00"), // Behind end date + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-1T00:00:00+08:00"), + End = DateTimeOffset.Parse("2023-9-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Daily + }, + Range = new RecurrenceRange() + { + Type = RecurrenceRangeType.EndDate, + EndDate = DateTimeOffset.Parse("2023-9-3T00:00:00+08:00") + } + } + }, + false ), + + ( DateTimeOffset.Parse("2023-9-2T16:00:00+00:00"), // 2023-9-3T00:00:00+08:00 + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-1T00:00:00+08:00"), + End = DateTimeOffset.Parse("2023-9-1T12:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Daily, + Interval = 2 + }, + Range = new RecurrenceRange() + } + }, + true ), + + ( DateTimeOffset.Parse("2023-9-2T15:59:59+00:00"), // 2023-9-2T23:59:59+08:00 + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-1T00:00:00+08:00"), + End = DateTimeOffset.Parse("2023-9-1T12:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Daily, + Interval = 2 + }, + Range = new RecurrenceRange() + } + }, + false ), + }; + + ConsumeEvaluationTestData(testData); + } + + [Fact] + public void MatchWeeklyRecurrenceTest() + { + var testData = new List>() + { + ( DateTimeOffset.Parse("2023-9-4T00:00:00+08:00"), // Monday in the 2nd week after the Start date + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-1T00:00:00+08:00"), // Friday + End = DateTimeOffset.Parse("2023-9-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + // FirstDayOfWeek is Sunday by default. + DaysOfWeek = new List(){ DayOfWeek.Monday, DayOfWeek.Friday } + }, + Range = new RecurrenceRange() + } + }, + true ), + + ( DateTimeOffset.Parse("2023-9-8T00:00:00+08:00"), // Friday in the 2nd week after the Start date + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-1T00:00:00+08:00"), // Friday + End = DateTimeOffset.Parse("2023-9-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + // FirstDayOfWeek is Sunday by default. + DaysOfWeek = new List(){ DayOfWeek.Monday, DayOfWeek.Friday } + }, + Range = new RecurrenceRange() + } + }, + true ), + + ( DateTimeOffset.Parse("2023-9-8T00:00:00+08:00"), // Friday in the 2nd week after the Start date + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-1T00:00:00+08:00"), // Friday + End = DateTimeOffset.Parse("2023-9-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + Interval = 2, + DaysOfWeek = new List(){ DayOfWeek.Friday } + }, + Range = new RecurrenceRange() + } + }, + false ), + + ( DateTimeOffset.Parse("2023-9-15T00:00:00+08:00"), // Friday in the 3rd week after the Start date + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-1T00:00:00+08:00"), // Friday + End = DateTimeOffset.Parse("2023-9-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + Interval = 2, + DaysOfWeek = new List(){ DayOfWeek.Friday } + }, + Range = new RecurrenceRange() + } + }, + true ), + + ( DateTimeOffset.Parse("2023-9-4T00:00:00+08:00"), // Monday is not included in DaysOfWeek. + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-1T00:00:00+08:00"), // Friday + End = DateTimeOffset.Parse("2023-9-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + DaysOfWeek = new List(){ DayOfWeek.Tuesday, DayOfWeek.Wednesday, DayOfWeek.Thursday, DayOfWeek.Friday, DayOfWeek.Saturday, DayOfWeek.Sunday } + }, + Range = new RecurrenceRange() + } + }, + false ), + + ( DateTimeOffset.Parse("2023-9-2T00:00:00+08:00"), // The 2nd occurrence + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-1T00:00:00+08:00"), + End = DateTimeOffset.Parse("2023-9-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + DaysOfWeek = new List(){ DayOfWeek.Monday, DayOfWeek.Tuesday, DayOfWeek.Wednesday, DayOfWeek.Thursday, DayOfWeek.Friday, DayOfWeek.Saturday, DayOfWeek.Sunday } + }, + Range = new RecurrenceRange() + { + Type = RecurrenceRangeType.Numbered, + NumberOfOccurrences = 1 + } + } + }, + false ), + + ( DateTimeOffset.Parse("2023-9-3T00:00:00+08:00"), // The 3rd occurrence + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-1T00:00:00+08:00"), + End = DateTimeOffset.Parse("2023-9-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + DaysOfWeek = new List(){ DayOfWeek.Monday, DayOfWeek.Tuesday, DayOfWeek.Wednesday, DayOfWeek.Thursday, DayOfWeek.Friday, DayOfWeek.Saturday, DayOfWeek.Sunday } + }, + Range = new RecurrenceRange() + { + Type = RecurrenceRangeType.Numbered, + NumberOfOccurrences = 2 + } + } + }, + false ), + + ( DateTimeOffset.Parse("2023-9-3T00:00:00+08:00"), // The 3rd occurrence + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-1T00:00:00+08:00"), + End = DateTimeOffset.Parse("2023-9-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + DaysOfWeek = new List(){ DayOfWeek.Monday, DayOfWeek.Tuesday, DayOfWeek.Wednesday, DayOfWeek.Thursday, DayOfWeek.Friday, DayOfWeek.Saturday, DayOfWeek.Sunday } + }, + Range = new RecurrenceRange() + { + Type = RecurrenceRangeType.Numbered, + NumberOfOccurrences = 3 + } + } + }, + true ), + + ( DateTimeOffset.Parse("2023-9-8T00:00:00+08:00"), // The 8th occurence + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-1T00:00:00+08:00"), // Friday + End = DateTimeOffset.Parse("2023-9-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + DaysOfWeek = new List(){ DayOfWeek.Monday, DayOfWeek.Tuesday, DayOfWeek.Wednesday, DayOfWeek.Thursday, DayOfWeek.Friday, DayOfWeek.Saturday, DayOfWeek.Sunday } + }, + Range = new RecurrenceRange() + { + Type = RecurrenceRangeType.Numbered, + NumberOfOccurrences = 7 + } + } + }, + false ), + + ( DateTimeOffset.Parse("2023-9-8T00:00:00+08:00"), // The 8th occurence + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-1T00:00:00+08:00"), // Friday + End = DateTimeOffset.Parse("2023-9-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + DaysOfWeek = new List(){ DayOfWeek.Monday, DayOfWeek.Tuesday, DayOfWeek.Wednesday, DayOfWeek.Thursday, DayOfWeek.Friday, DayOfWeek.Saturday, DayOfWeek.Sunday } + }, + Range = new RecurrenceRange() + { + Type = RecurrenceRangeType.Numbered, + NumberOfOccurrences = 8 + } + } + }, + true ), + + ( DateTimeOffset.Parse("2024-1-18T00:30:00+08:00"), // The 4th occurence + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2024-1-4T00:00:00+08:00"), // Thursday + End = DateTimeOffset.Parse("2024-1-4T01:00:00+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + // FirstDayOfWeek is Sunday by default. + DaysOfWeek = new List(){ DayOfWeek.Tuesday, DayOfWeek.Thursday, DayOfWeek.Friday}, + Interval = 2 + }, + Range = new RecurrenceRange() + { + Type = RecurrenceRangeType.Numbered, + NumberOfOccurrences = 3 + } + } + }, + false ), + + ( DateTimeOffset.Parse("2024-1-18T00:30:00+08:00"), // The 4th occurence + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2024-1-4T00:00:00+08:00"), // Thursday + End = DateTimeOffset.Parse("2024-1-4T01:00:00+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + // FirstDayOfWeek is Sunday by default. + DaysOfWeek = new List(){ DayOfWeek.Tuesday, DayOfWeek.Thursday, DayOfWeek.Friday}, + Interval = 2 + }, + Range = new RecurrenceRange() + { + Type = RecurrenceRangeType.Numbered, + NumberOfOccurrences = 4 + } + } + }, + true ), + + ( DateTimeOffset.Parse("2023-9-4T00:00:00+08:00"), // Monday in the 2nd week after the Start date + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-3T00:00:00+08:00"), // Sunday + End = DateTimeOffset.Parse("2023-9-3T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + Interval = 2, + FirstDayOfWeek = DayOfWeek.Monday, // 2023-9-3 is the last day of the 1st week after the Start date + DaysOfWeek = new List(){ DayOfWeek.Monday, DayOfWeek.Sunday } + }, + Range = new RecurrenceRange() + } + }, + false ), + + ( DateTimeOffset.Parse("2023-9-4T00:00:00+08:00"), // Monday in the 1st week after the Start date + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-3T00:00:00+08:00"), // Sunday + End = DateTimeOffset.Parse("2023-9-3T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + Interval = 2, + // FirstDayOfWeek is Sunday by default, 2023-9-3 ~ 2023-9-9 is the 1st week after the Start date + DaysOfWeek = new List(){ DayOfWeek.Monday, DayOfWeek.Sunday } + }, + Range = new RecurrenceRange() + } + }, + true ), + + ( DateTimeOffset.Parse("2023-9-18T00:00:00+08:00"), // Monday in the 4th week after the Start date + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-3T00:00:00+08:00"), + End = DateTimeOffset.Parse("2023-9-3T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + Interval = 2, + FirstDayOfWeek = DayOfWeek.Monday, // 2023-9-3 1st week, 9-4 ~ 9-10 2nd week (Skipped), 9-11 ~ 9-17 3rd week, 9-18 ~ 9-24 4th week (Skipped) + DaysOfWeek = new List(){ DayOfWeek.Monday, DayOfWeek.Sunday } + }, + Range = new RecurrenceRange() + } + }, + false ), + + ( DateTimeOffset.Parse("2023-9-18T00:00:00+08:00"), // Monday in the 3rd week after the Start date + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-3T00:00:00+08:00"), // Sunday + End = DateTimeOffset.Parse("2023-9-3T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + Interval = 2, + // FirstDayOfWeek is Sunday by default, 2023-9-3 ~ 9-9 1st week, 9-17 ~ 9-23 3rd week + DaysOfWeek = new List(){ DayOfWeek.Monday, DayOfWeek.Sunday } + }, + Range = new RecurrenceRange() + } + }, + true ), + + ( DateTimeOffset.Parse("2023-9-17T00:00:00+08:00"), // Sunday in the 3rd week after the Start date + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-3T00:00:00+08:00"), // Sunday + End = DateTimeOffset.Parse("2023-9-3T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + Interval = 2, + FirstDayOfWeek = DayOfWeek.Monday, // 2023-9-3 1st week, 9-11 ~ 9-17 3rd week + DaysOfWeek = new List(){ DayOfWeek.Monday, DayOfWeek.Sunday } // 2023-9-3, 9-11. 9-17 + }, + Range = new RecurrenceRange() + { + Type = RecurrenceRangeType.Numbered, + NumberOfOccurrences = 3 + } + } + }, + true ), + + ( DateTimeOffset.Parse("2024-2-12T08:00:00+08:00"), // Monday in the 3rd week after the Start date + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2024-2-2T12:00:00+08:00"), // Friday + End = DateTimeOffset.Parse("2024-2-3T12:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + Interval = 2, + FirstDayOfWeek = DayOfWeek.Sunday, + DaysOfWeek = new List(){ DayOfWeek.Monday, DayOfWeek.Friday } + }, + Range = new RecurrenceRange() + } + }, + false ), + + ( DateTimeOffset.Parse("2023-9-13T00:00:00+08:00"), // Within the recurring time window 2023-9-11T:00:00:00+08:00 ~ 2023-9-15T:00:00:00+08:00 + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-3T00:00:00+08:00"), // Sunday + End = DateTimeOffset.Parse("2023-9-7T00:00:00+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + Interval = 2, + FirstDayOfWeek = DayOfWeek.Monday, + DaysOfWeek = new List(){ DayOfWeek.Monday, DayOfWeek.Sunday } // Time window occurrences: 9-3 ~ 9-7 (1st week), 9-11 ~ 9-15 and 9-17 ~ 9-21 (3rd week) + }, + Range = new RecurrenceRange() + } + }, + true ), + + ( DateTimeOffset.Parse("2023-9-19T00:00:00+08:00"), // The 3rd occurrence: 2023-9-17T:00:00:00+08:00 ~ 2023-9-21T:00:00:00+08:00 + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-3T00:00:00+08:00"), // Sunday + End = DateTimeOffset.Parse("2023-9-7T00:00:00+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + Interval = 2, + FirstDayOfWeek = DayOfWeek.Monday, + DaysOfWeek = new List(){ DayOfWeek.Monday, DayOfWeek.Sunday } // Time window occurrences: 9-3 ~ 9-7 (1st week), 9-11 ~ 9-15 and 9-17 ~ 9-21 (3rd week) + }, + Range = new RecurrenceRange() + { + Type = RecurrenceRangeType.Numbered, + NumberOfOccurrences = 3 + } + } + }, + true ), + + ( DateTimeOffset.Parse("2023-9-19T00:00:00+08:00"), // The 3rd occurrence + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-3T00:00:00+08:00"), // Sunday + End = DateTimeOffset.Parse("2023-9-7T00:00:00+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + Interval = 2, + FirstDayOfWeek = DayOfWeek.Monday, + DaysOfWeek = new List(){ DayOfWeek.Monday, DayOfWeek.Sunday } // Time window occurrences: 9-3 ~ 9-7 (1st week), 9-11 ~ 9-15 and 9-17 ~ 9-21 (3rd week) + }, + Range = new RecurrenceRange() + { + Type = RecurrenceRangeType.Numbered, + NumberOfOccurrences = 2 + } + } + }, + false ), + + ( DateTimeOffset.Parse("2023-9-3T16:00:00+00:00"), // Monday in the 2nd week after the Start date if timezone is UTC+8 + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-1T00:00:00+08:00"), // Friday + End = DateTimeOffset.Parse("2023-9-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + // FirstDayOfWeek is Sunday by default. + DaysOfWeek = new List(){ DayOfWeek.Monday, DayOfWeek.Friday } + }, + Range = new RecurrenceRange() + } + }, + true ), + + ( DateTimeOffset.Parse("2023-9-7T16:00:00+00:00"), // Friday in the 2nd week after the Start date if timezone is UTC+8 + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-1T00:00:00+08:00"), // Friday + End = DateTimeOffset.Parse("2023-9-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + // FirstDayOfWeek is Sunday by default. + DaysOfWeek = new List(){ DayOfWeek.Monday, DayOfWeek.Friday } + }, + Range = new RecurrenceRange() + } + }, + true ), + + ( DateTimeOffset.Parse("2023-9-3T15:59:59+00:00"), + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-1T00:00:00+08:00"), // Friday + End = DateTimeOffset.Parse("2023-9-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + // FirstDayOfWeek is Sunday by default. + DaysOfWeek = new List(){ DayOfWeek.Monday, DayOfWeek.Friday } + }, + Range = new RecurrenceRange() + } + }, + false ), + + ( DateTimeOffset.Parse("2023-9-7T15:59:59+00:00"), + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-1T00:00:00+08:00"), // Friday + End = DateTimeOffset.Parse("2023-9-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + // FirstDayOfWeek is Sunday by default. + DaysOfWeek = new List(){ DayOfWeek.Monday, DayOfWeek.Friday } + }, + Range = new RecurrenceRange() + } + }, + false ), + + ( DateTimeOffset.Parse("2023-9-10T16:00:00+00:00"), // Within the recurring time window 2023-9-11T:00:00:00+08:00 ~ 2023-9-15T:00:00:00+08:00 + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-3T00:00:00+08:00"), // Sunday + End = DateTimeOffset.Parse("2023-9-7T00:00:00+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + Interval = 2, + FirstDayOfWeek = DayOfWeek.Monday, + DaysOfWeek = new List(){ DayOfWeek.Monday, DayOfWeek.Sunday } // Time window occurrences: 9-3 ~ 9-7 (1st week), 9-11 ~ 9-15 and 9-17 ~ 9-21 (3rd week) + }, + Range = new RecurrenceRange() + } + }, + true ), + + ( DateTimeOffset.Parse("2023-9-10T15:59:59+00:00"), + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2023-9-3T00:00:00+08:00"), // Sunday + End = DateTimeOffset.Parse("2023-9-7T00:00:00+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + Interval = 2, + FirstDayOfWeek = DayOfWeek.Monday, + DaysOfWeek = new List(){ DayOfWeek.Monday, DayOfWeek.Sunday } // Time window occurrences: 9-3 ~ 9-7 (1st week), 9-11 ~ 9-15 and 9-17 ~ 9-21 (3rd week) + }, + Range = new RecurrenceRange() + } + }, + false ) + }; + + ConsumeEvaluationTestData(testData); + } + + [Fact] + public void FindDailyClosestStartTest() + { + var testData = new List>() + { + ( DateTimeOffset.Parse("2024-2-28T00:00:00+08:00"), + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2024-3-1T00:00:00+08:00"), + End = DateTimeOffset.Parse("2024-3-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Daily + }, + Range = new RecurrenceRange() + } + }, + DateTimeOffset.Parse("2024-3-1T00:00:00+08:00")), + + ( DateTimeOffset.Parse("2024-2-28T00:00:00+08:00"), + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2024-2-1T00:00:00+08:00"), + End = DateTimeOffset.Parse("2024-2-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Daily + }, + Range = new RecurrenceRange() + } + }, + DateTimeOffset.Parse("2024-2-28T00:00:00+08:00")), + + ( DateTimeOffset.Parse("2024-2-28T00:00:00+08:00"), + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2024-2-1T00:00:00+08:00"), + End = DateTimeOffset.Parse("2024-2-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Daily, + Interval = 2 + }, + Range = new RecurrenceRange() + } + }, + DateTimeOffset.Parse("2024-2-29T00:00:00+08:00")), + + ( DateTimeOffset.Parse("2024-2-28T00:00:00+08:00"), + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2024-2-1T00:00:00+08:00"), + End = DateTimeOffset.Parse("2024-2-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Daily, + Interval = 3 + }, + Range = new RecurrenceRange() + } + }, + DateTimeOffset.Parse("2024-2-28T00:00:00+08:00")), + + ( DateTimeOffset.Parse("2024-2-28T00:00:00+08:00"), + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2024-2-1T00:00:00+08:00"), + End = DateTimeOffset.Parse("2024-2-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Daily, + }, + Range = new RecurrenceRange() + { + Type = RecurrenceRangeType.Numbered, + NumberOfOccurrences = 27 + } + } + }, + null), + + ( DateTimeOffset.Parse("2024-2-28T00:00:00+08:00"), + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2024-2-1T00:00:00+08:00"), + End = DateTimeOffset.Parse("2024-2-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Daily, + }, + Range = new RecurrenceRange() + { + Type = RecurrenceRangeType.Numbered, + NumberOfOccurrences = 28 + } + } + }, + DateTimeOffset.Parse("2024-2-28T00:00:00+08:00")), + + ( DateTimeOffset.Parse("2024-2-28T00:00:00+08:00"), + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2024-2-1T00:00:00+08:00"), + End = DateTimeOffset.Parse("2024-2-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Daily, + }, + Range = new RecurrenceRange() + { + Type = RecurrenceRangeType.EndDate, + EndDate = DateTimeOffset.Parse("2024-2-27T00:00:00+08:00") + } + } + }, + null), + + ( DateTimeOffset.Parse("2024-2-28T00:00:00+08:00"), + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2024-2-1T00:00:00+08:00"), + End = DateTimeOffset.Parse("2024-2-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Daily, + }, + Range = new RecurrenceRange() + { + Type = RecurrenceRangeType.EndDate, + EndDate = DateTimeOffset.Parse("2024-2-28T00:00:00+08:00") + } + } + }, + DateTimeOffset.Parse("2024-2-28T00:00:00+08:00")) + }; + + ConsumeEvalutationTestData(testData); + } + + [Fact] + public void FindWeeklyClosestStartTest() + { + var testData = new List>() + { + ( DateTimeOffset.Parse("2024-2-1T00:00:00+08:00"), + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2024-2-29T00:00:00+08:00"), // Thursday + End = DateTimeOffset.Parse("2024-2-29T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + DaysOfWeek = new List(){ DayOfWeek.Thursday } + }, + Range = new RecurrenceRange() + } + }, + DateTimeOffset.Parse("2024-2-29T00:00:00+08:00")), + + ( DateTimeOffset.Parse("2024-2-29T00:00:00+08:00"), + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2024-2-29T00:00:00+08:00"), // Thursday + End = DateTimeOffset.Parse("2024-2-29T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + DaysOfWeek = new List(){ DayOfWeek.Thursday } + }, + Range = new RecurrenceRange() + } + }, + DateTimeOffset.Parse("2024-2-29T00:00:00+08:00")), + + ( DateTimeOffset.Parse("2024-2-29T00:00:00+08:00"), + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2024-2-1T12:00:00+08:00"), // Thursday + End = DateTimeOffset.Parse("2024-2-1T12:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + DaysOfWeek = new List(){ DayOfWeek.Thursday } + }, + Range = new RecurrenceRange() + } + }, + DateTimeOffset.Parse("2024-2-29T12:00:00+08:00")), + + ( DateTimeOffset.Parse("2024-3-1T00:00:00+08:00"), + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2024-2-1T00:00:00+08:00"), // Thursday + End = DateTimeOffset.Parse("2024-2-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + DaysOfWeek = new List(){ DayOfWeek.Thursday, DayOfWeek.Sunday } + }, + Range = new RecurrenceRange() + } + }, + DateTimeOffset.Parse("2024-3-3T00:00:00+08:00")), + + ( DateTimeOffset.Parse("2024-2-28T00:00:00+08:00"), + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2024-2-1T00:00:00+08:00"), // Thursday + End = DateTimeOffset.Parse("2024-2-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + Interval = 2, + // FirstDayOfWeek is Sunday by default. + DaysOfWeek = new List(){ DayOfWeek.Thursday, DayOfWeek.Sunday } + }, + Range = new RecurrenceRange() + } + }, + DateTimeOffset.Parse("2024-2-29T00:00:00+08:00")), + + ( DateTimeOffset.Parse("2024-2-1T00:00:00+08:00"), + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2024-2-1T00:00:00+08:00"), // Thursday + End = DateTimeOffset.Parse("2024-2-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + Interval = 2, + // FirstDayOfWeek is Sunday by default. + DaysOfWeek = new List(){ DayOfWeek.Thursday, DayOfWeek.Sunday } + }, + Range = new RecurrenceRange() + } + }, + DateTimeOffset.Parse("2024-2-1T00:00:00+08:00")), + + ( DateTimeOffset.Parse("2024-2-1T00:00:00+08:00"), + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2024-2-1T00:00:00+08:00"), // Thursday + End = DateTimeOffset.Parse("2024-2-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + DaysOfWeek = new List(){ DayOfWeek.Monday, DayOfWeek.Tuesday, DayOfWeek.Wednesday, DayOfWeek.Thursday, DayOfWeek.Friday, DayOfWeek.Saturday, DayOfWeek.Sunday } + }, + Range = new RecurrenceRange() + { + Type = RecurrenceRangeType.Numbered, + NumberOfOccurrences = 1 + } + } + }, + DateTimeOffset.Parse("2024-2-1T00:00:00+08:00")), + + ( DateTimeOffset.Parse("2024-2-2T00:00:00+08:00"), + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2024-2-1T00:00:00+08:00"), // Thursday + End = DateTimeOffset.Parse("2024-2-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + DaysOfWeek = new List(){ DayOfWeek.Monday, DayOfWeek.Tuesday, DayOfWeek.Wednesday, DayOfWeek.Thursday, DayOfWeek.Friday, DayOfWeek.Saturday, DayOfWeek.Sunday } + }, + Range = new RecurrenceRange() + { + Type = RecurrenceRangeType.Numbered, + NumberOfOccurrences = 1 + } + } + }, + null), + + ( DateTimeOffset.Parse("2024-2-11T00:00:00+08:00"), + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2024-2-1T00:00:00+08:00"), // Thursday + End = DateTimeOffset.Parse("2024-2-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + Interval = 2, + // FirstDayOfWeek is Sunday by default. + DaysOfWeek = new List(){ DayOfWeek.Thursday, DayOfWeek.Sunday } + }, + Range = new RecurrenceRange() + { + Type = RecurrenceRangeType.Numbered, + NumberOfOccurrences = 2 + } + } + }, + DateTimeOffset.Parse("2024-2-11T00:00:00+08:00")), // Sunday in the 3rd week + + ( DateTimeOffset.Parse("2024-2-11T00:00:00+08:00"), + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2024-2-1T00:00:00+08:00"), // Thursday + End = DateTimeOffset.Parse("2024-2-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + Interval = 2, + FirstDayOfWeek = DayOfWeek.Monday, + DaysOfWeek = new List(){ DayOfWeek.Thursday, DayOfWeek.Sunday } + }, + Range = new RecurrenceRange() + { + Type = RecurrenceRangeType.Numbered, + NumberOfOccurrences = 2 + } + } + }, + null), + + ( DateTimeOffset.Parse("2024-2-12T00:00:00+08:00"), // Monday in the 3rd week + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2024-2-1T00:00:00+08:00"), // Thursday + End = DateTimeOffset.Parse("2024-2-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + Interval = 2, + // FirstDayOfWeek is Sunday by default. + DaysOfWeek = new List(){ DayOfWeek.Thursday, DayOfWeek.Sunday } + }, + Range = new RecurrenceRange() + { + Type = RecurrenceRangeType.Numbered, + NumberOfOccurrences = 3 + } + } + }, + DateTimeOffset.Parse("2024-2-15T00:00:00+08:00")), // Thursday in the 3rd week + + ( DateTimeOffset.Parse("2024-2-12T00:00:00+08:00"), // Monday in the 3rd week + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2024-2-1T00:00:00+08:00"), // Thursday + End = DateTimeOffset.Parse("2024-2-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + Interval = 2, + FirstDayOfWeek = DayOfWeek.Monday, + DaysOfWeek = new List(){ DayOfWeek.Thursday, DayOfWeek.Sunday } + }, + Range = new RecurrenceRange() + { + Type = RecurrenceRangeType.Numbered, + NumberOfOccurrences = 3 + } + } + }, + DateTimeOffset.Parse("2024-2-15T00:00:00+08:00")), // Thursday in the 3rd week + + ( DateTimeOffset.Parse("2024-2-11T00:00:00+08:00"), // Sunday in the 3rd week + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2024-2-1T12:00:00+08:00"), // Thursday + End = DateTimeOffset.Parse("2024-2-1T12:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + Interval = 2, + // FirstDayOfWeek is Sunday by default. + DaysOfWeek = new List(){ DayOfWeek.Monday, DayOfWeek.Tuesday, DayOfWeek.Wednesday, DayOfWeek.Thursday, DayOfWeek.Friday, DayOfWeek.Saturday, DayOfWeek.Sunday } + }, + Range = new RecurrenceRange() + { + Type = RecurrenceRangeType.Numbered, + NumberOfOccurrences = 3 + } + } + }, + null), + + ( DateTimeOffset.Parse("2024-2-11T00:00:00+08:00"), // Sunday in the 2nd week + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2024-2-1T12:00:00+08:00"), // Thursday + End = DateTimeOffset.Parse("2024-2-1T12:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + Interval = 2, + FirstDayOfWeek = DayOfWeek.Monday, + DaysOfWeek = new List(){ DayOfWeek.Monday, DayOfWeek.Tuesday, DayOfWeek.Wednesday, DayOfWeek.Thursday, DayOfWeek.Friday, DayOfWeek.Saturday, DayOfWeek.Sunday } + }, + Range = new RecurrenceRange() + { + Type = RecurrenceRangeType.Numbered, + NumberOfOccurrences = 3 + } + } + }, + null), + + ( DateTimeOffset.Parse("2024-2-11T00:00:00+08:00"), + new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2024-2-1T00:00:00+08:00"), // Thursday + End = DateTimeOffset.Parse("2024-2-1T00:00:01+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + DaysOfWeek = new List(){ DayOfWeek.Thursday, DayOfWeek.Sunday } + }, + Range = new RecurrenceRange() + { + Type = RecurrenceRangeType.EndDate, + EndDate = DateTimeOffset.Parse("2024-2-8T00:00:00+08:00") + } + } + }, + null) + }; + + ConsumeEvalutationTestData(testData); + } + + [Fact] + public async void RecurrenceEvaluationThroughCacheTest() + { + OnDemandClock mockedTimeProvider = new OnDemandClock(); + + var mockedTimeWindowFilter = new TimeWindowFilter() + { + Cache = new MemoryCache(new MemoryCacheOptions()), + SystemClock = mockedTimeProvider + }; + + var context = new FeatureFilterEvaluationContext() + { + Settings = new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2024-2-1T00:00:00+08:00"), // Thursday + End = DateTimeOffset.Parse("2024-2-1T12:00:00+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Daily, + Interval = 2 + }, + Range = new RecurrenceRange() + { + Type = RecurrenceRangeType.EndDate, + EndDate = DateTimeOffset.Parse("2024-2-5T12:00:00+08:00") + } + } + } + }; + + mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-2-2T23:00:00+08:00"); + + Assert.False(await mockedTimeWindowFilter.EvaluateAsync(context)); + + for (int i = 0; i < 12; i++) + { + mockedTimeProvider.UtcNow = mockedTimeProvider.UtcNow.AddHours(1); + Assert.True(await mockedTimeWindowFilter.EvaluateAsync(context)); + } + + mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-2-3T11:59:59+08:00"); + Assert.True(await mockedTimeWindowFilter.EvaluateAsync(context)); + + mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-2-3T12:00:00+08:00"); + Assert.False(await mockedTimeWindowFilter.EvaluateAsync(context)); + + mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-2-5T00:00:00+08:00"); + Assert.True(await mockedTimeWindowFilter.EvaluateAsync(context)); + + mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-2-5T12:00:00+08:00"); + Assert.False(await mockedTimeWindowFilter.EvaluateAsync(context)); + + mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-2-7T00:00:00+08:00"); + Assert.False(await mockedTimeWindowFilter.EvaluateAsync(context)); + + for (int i = 0; i < 10; i++ ) + { + mockedTimeProvider.UtcNow = mockedTimeProvider.UtcNow.AddDays(1); + Assert.False(await mockedTimeWindowFilter.EvaluateAsync(context)); + } + + context = new FeatureFilterEvaluationContext() + { + Settings = new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2024-2-1T00:00:00+08:00"), // Thursday + End = DateTimeOffset.Parse("2024-2-1T12:00:00+08:00"), + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + // FirstDayOfWeek is Sunday by default. + DaysOfWeek = new List() { DayOfWeek.Thursday, DayOfWeek.Sunday } + }, + Range = new RecurrenceRange() + { + Type = RecurrenceRangeType.Numbered, + NumberOfOccurrences = 2 + } + } + } + }; + + mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-1-31T23:00:00+08:00"); + Assert.False(await mockedTimeWindowFilter.EvaluateAsync(context)); + + for (int i = 0; i < 12; i++) + { + mockedTimeProvider.UtcNow = mockedTimeProvider.UtcNow.AddHours(1); + Assert.True(await mockedTimeWindowFilter.EvaluateAsync(context)); + } + + mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-2-1T11:59:59+08:00"); + Assert.True(await mockedTimeWindowFilter.EvaluateAsync(context)); + + mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-2-1T12:00:00+08:00"); + Assert.False(await mockedTimeWindowFilter.EvaluateAsync(context)); + + mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-2-2T00:00:00+08:00"); // Friday + Assert.False(await mockedTimeWindowFilter.EvaluateAsync(context)); + + mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-2-4T00:00:00+08:00"); // Sunday + Assert.True(await mockedTimeWindowFilter.EvaluateAsync(context)); + + mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-2-4T06:00:00+08:00"); + Assert.True(await mockedTimeWindowFilter.EvaluateAsync(context)); + + mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-2-4T12:01:00+08:00"); + Assert.False(await mockedTimeWindowFilter.EvaluateAsync(context)); + + mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-2-8T00:00:00+08:00"); + Assert.False(await mockedTimeWindowFilter.EvaluateAsync(context)); + + for (int i = 0; i < 10; i++) + { + mockedTimeProvider.UtcNow = mockedTimeProvider.UtcNow.AddDays(1); + Assert.False(await mockedTimeWindowFilter.EvaluateAsync(context)); + } + } + } +} diff --git a/tests/Tests.FeatureManagement/appsettings.json b/tests/Tests.FeatureManagement/appsettings.json index 509d8ba8..336f3a8b 100644 --- a/tests/Tests.FeatureManagement/appsettings.json +++ b/tests/Tests.FeatureManagement/appsettings.json @@ -18,7 +18,36 @@ { "Name": "Percentage", "Parameters": { - "Value": 100 + "Value": 100 + } + } + ] + }, + "RecurringTimeWindowTestFeature": { + "EnabledFor": [ + { + "Name": "TimeWindow", + "Parameters": { + "Start": "Sun, 14 Jan 2024 00:00:00 GMT", + "End": "Mon, 15 Jan 2024 00:00:00 GMT", + "Recurrence": { + "Pattern": { + "Type": "Weekly", + "DaysOfWeek": [ + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday" + ], + "FirstDayOfWeek": "Monday" + }, + "Range": { + "Type": "NoEnd" + } + } } } ] From dbaa26224244965f4e30c35d7b7556a9465da9a1 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang <141655842+zhiyuanliang-ms@users.noreply.github.com> Date: Tue, 23 Apr 2024 23:58:12 +0800 Subject: [PATCH 08/10] Fix typo in README (#433) * Fix type in README * fix typo --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index c8e59665..9137c6ed 100644 --- a/README.md +++ b/README.md @@ -694,8 +694,8 @@ There are three possible recurrence range type: `NoEnd`, `EndDate` and `Numbered "Recurrence":{ "Pattern": { "Type": "Weekly", - "Interval": 1 - "DaysOfWeek": ["Monday", "Tuesday"], + "Interval": 1, + "DaysOfWeek": ["Monday", "Tuesday"] }, "Range": { "Type": "Numbered", @@ -802,8 +802,8 @@ IFeatureManager fm; TargetingContext targetingContext = new TargetingContext { UserId = userId, - Groups = groups; -} + Groups = groups +}; await fm.IsEnabledAsync(featureName, targetingContext); ``` From 04c2ba2c5fb188e02bf18e2f598fc8d03b2aaf94 Mon Sep 17 00:00:00 2001 From: Ross Grambo Date: Tue, 30 Apr 2024 12:35:33 -0700 Subject: [PATCH 09/10] Moves cache busting and handles null (#438) * Moves cache busting and handles null * Adjusted null check * Remove formatting --- .../ConfigurationFeatureDefinitionProvider.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/Microsoft.FeatureManagement/ConfigurationFeatureDefinitionProvider.cs b/src/Microsoft.FeatureManagement/ConfigurationFeatureDefinitionProvider.cs index c86113e0..3ae10329 100644 --- a/src/Microsoft.FeatureManagement/ConfigurationFeatureDefinitionProvider.cs +++ b/src/Microsoft.FeatureManagement/ConfigurationFeatureDefinitionProvider.cs @@ -133,7 +133,14 @@ public async IAsyncEnumerable GetAllFeatureDefinitionsAsync() // // Underlying IConfigurationSection data is dynamic so latest feature definitions are returned - yield return _definitions.GetOrAdd(featureName, (_) => ReadFeatureDefinition(featureSection)); + FeatureDefinition definition = _definitions.GetOrAdd(featureName, (_) => ReadFeatureDefinition(featureSection)); + + // + // Null cache entry possible if someone accesses non-existent flag directly (IsEnabled) + if (definition != null) + { + yield return definition; + } } } From f9beae58f1569ed45738adf7e578cf552f2b5a93 Mon Sep 17 00:00:00 2001 From: Ross Grambo Date: Tue, 7 May 2024 14:05:33 -0700 Subject: [PATCH 10/10] Version bump (#443) --- .../Microsoft.FeatureManagement.AspNetCore.csproj | 2 +- .../Microsoft.FeatureManagement.csproj | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Microsoft.FeatureManagement.AspNetCore/Microsoft.FeatureManagement.AspNetCore.csproj b/src/Microsoft.FeatureManagement.AspNetCore/Microsoft.FeatureManagement.AspNetCore.csproj index 7c65cfbe..aca96791 100644 --- a/src/Microsoft.FeatureManagement.AspNetCore/Microsoft.FeatureManagement.AspNetCore.csproj +++ b/src/Microsoft.FeatureManagement.AspNetCore/Microsoft.FeatureManagement.AspNetCore.csproj @@ -4,7 +4,7 @@ 3 - 2 + 3 0 diff --git a/src/Microsoft.FeatureManagement/Microsoft.FeatureManagement.csproj b/src/Microsoft.FeatureManagement/Microsoft.FeatureManagement.csproj index adbb2168..4b99efcf 100644 --- a/src/Microsoft.FeatureManagement/Microsoft.FeatureManagement.csproj +++ b/src/Microsoft.FeatureManagement/Microsoft.FeatureManagement.csproj @@ -4,7 +4,7 @@ 3 - 2 + 3 0