feat(schedules): implemented the basic schedule
ci/woodpecker/push/woodpecker Pipeline failed Details

This commit is contained in:
D. Moonfire 2023-01-16 22:10:24 -06:00
parent c73805ae93
commit d5b975c179
18 changed files with 620 additions and 430 deletions

View File

@ -51,6 +51,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MfGames.Nitride.Json", "src
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MfGames.Nitride.Json.Tests", "tests\MfGames.Nitride.Json.Tests\MfGames.Nitride.Json.Tests.csproj", "{7CCC3A82-D5FE-4D54-9751-5E7985DE1F26}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MfGames.Nitride.Temporal.Schedules", "src\MfGames.Nitride.Temporal.Schedules\MfGames.Nitride.Temporal.Schedules.csproj", "{6AC8F985-B11B-44F4-A000-DFEAFEF59754}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MfGames.Nitride.Temporal.Schedules.Tests", "tests\MfGames.Nitride.Temporal.Schedules.Tests\MfGames.Nitride.Temporal.Schedules.Tests.csproj", "{CA009524-E64A-4380-874E-C9D19D868572}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -316,6 +320,30 @@ Global
{7CCC3A82-D5FE-4D54-9751-5E7985DE1F26}.Release|x64.Build.0 = Release|Any CPU
{7CCC3A82-D5FE-4D54-9751-5E7985DE1F26}.Release|x86.ActiveCfg = Release|Any CPU
{7CCC3A82-D5FE-4D54-9751-5E7985DE1F26}.Release|x86.Build.0 = Release|Any CPU
{6AC8F985-B11B-44F4-A000-DFEAFEF59754}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6AC8F985-B11B-44F4-A000-DFEAFEF59754}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6AC8F985-B11B-44F4-A000-DFEAFEF59754}.Debug|x64.ActiveCfg = Debug|Any CPU
{6AC8F985-B11B-44F4-A000-DFEAFEF59754}.Debug|x64.Build.0 = Debug|Any CPU
{6AC8F985-B11B-44F4-A000-DFEAFEF59754}.Debug|x86.ActiveCfg = Debug|Any CPU
{6AC8F985-B11B-44F4-A000-DFEAFEF59754}.Debug|x86.Build.0 = Debug|Any CPU
{6AC8F985-B11B-44F4-A000-DFEAFEF59754}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6AC8F985-B11B-44F4-A000-DFEAFEF59754}.Release|Any CPU.Build.0 = Release|Any CPU
{6AC8F985-B11B-44F4-A000-DFEAFEF59754}.Release|x64.ActiveCfg = Release|Any CPU
{6AC8F985-B11B-44F4-A000-DFEAFEF59754}.Release|x64.Build.0 = Release|Any CPU
{6AC8F985-B11B-44F4-A000-DFEAFEF59754}.Release|x86.ActiveCfg = Release|Any CPU
{6AC8F985-B11B-44F4-A000-DFEAFEF59754}.Release|x86.Build.0 = Release|Any CPU
{CA009524-E64A-4380-874E-C9D19D868572}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{CA009524-E64A-4380-874E-C9D19D868572}.Debug|Any CPU.Build.0 = Debug|Any CPU
{CA009524-E64A-4380-874E-C9D19D868572}.Debug|x64.ActiveCfg = Debug|Any CPU
{CA009524-E64A-4380-874E-C9D19D868572}.Debug|x64.Build.0 = Debug|Any CPU
{CA009524-E64A-4380-874E-C9D19D868572}.Debug|x86.ActiveCfg = Debug|Any CPU
{CA009524-E64A-4380-874E-C9D19D868572}.Debug|x86.Build.0 = Debug|Any CPU
{CA009524-E64A-4380-874E-C9D19D868572}.Release|Any CPU.ActiveCfg = Release|Any CPU
{CA009524-E64A-4380-874E-C9D19D868572}.Release|Any CPU.Build.0 = Release|Any CPU
{CA009524-E64A-4380-874E-C9D19D868572}.Release|x64.ActiveCfg = Release|Any CPU
{CA009524-E64A-4380-874E-C9D19D868572}.Release|x64.Build.0 = Release|Any CPU
{CA009524-E64A-4380-874E-C9D19D868572}.Release|x86.ActiveCfg = Release|Any CPU
{CA009524-E64A-4380-874E-C9D19D868572}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{D480943C-764D-4A8A-B546-642ED10586BB} = {570184FD-ECE4-4EC8-86E1-C1265E17D647}
@ -339,5 +367,7 @@ Global
{2AAE2B69-A93D-4045-B7E6-A32ED08D0D65} = {251D9C68-34EB-439D-B167-688BCC47DA17}
{9A0D2BEE-859A-4E74-8CA7-5E0FB7C2B113} = {570184FD-ECE4-4EC8-86E1-C1265E17D647}
{7CCC3A82-D5FE-4D54-9751-5E7985DE1F26} = {251D9C68-34EB-439D-B167-688BCC47DA17}
{6AC8F985-B11B-44F4-A000-DFEAFEF59754} = {570184FD-ECE4-4EC8-86E1-C1265E17D647}
{CA009524-E64A-4380-874E-C9D19D868572} = {251D9C68-34EB-439D-B167-688BCC47DA17}
EndGlobalSection
EndGlobal

View File

@ -1,6 +1,85 @@
using System.Collections.Generic;
using System.Linq;
using FluentValidation;
using MfGames.Gallium;
using MfGames.Nitride.Generators;
namespace MfGames.Nitride.Temporal.Schedules;
public interface ApplySchedules
public enum SchedulePeriod
{
/// <summary>
/// Indicates that all the matching schedule periods are instant (zero time).
/// </summary>
Instant,
/// <summary>
/// Indicates that the entities will be scheduled a day apart.
/// </summary>
Day,
/// <summary>
/// Indicates that the entities will be scheduled seven days apart.
/// </summary>
Week,
}
/// <summary>
/// Applies schedules against the list of entities.
/// </summary>
[WithProperties]
public partial class ApplySchedules : OperationBase
{
private readonly IValidator<ApplySchedules> validator;
public ApplySchedules(
IValidator<ApplySchedules> validator,
Timekeeper timekeeper)
{
this.Timekeeper = timekeeper;
this.validator = validator;
this.Schedules = new List<ISchedule>();
}
/// <summary>
/// Gets or sets the ordered list of schedules to apply to the entities.
/// </summary>
public IList<ISchedule> Schedules { get; set; }
/// <summary>
/// Gets or sets the timekeeper associated with this operation.
/// </summary>
public Timekeeper Timekeeper { get; set; }
/// <inheritdoc />
public override IEnumerable<Entity> Run(IEnumerable<Entity> input)
{
this.validator.ValidateAndThrow(this);
return input.Select(this.Apply);
}
public ApplySchedules WithSchedules<TItem>(IEnumerable<TItem> items)
where TItem : ISchedule
{
this.Schedules = items.OfType<ISchedule>().ToList();
return this;
}
private Entity Apply(Entity entity)
{
foreach (ISchedule schedule in this.Schedules)
{
if (!schedule.CanApply(entity))
{
continue;
}
entity = schedule.Apply(entity, this.Timekeeper);
}
return entity;
}
}

View File

@ -8,17 +8,23 @@ namespace MfGames.Nitride.Temporal.Schedules;
/// </summary>
public interface ISchedule
{
/// <summary>
/// Applies the schedule changes to the entity and returns the results.
/// </summary>
/// <param name="entity">The entity to update.</param>
/// <param name="timekeeper">The timekeeper for apply.</param>
/// <returns>
/// The modified entity, if the changes can be applied, otherwise the same
/// entity.
/// </returns>
Entity Apply(
Entity entity,
Timekeeper timekeeper);
/// <summary>
/// Determines if a schedule applies to a given entity.
/// </summary>
/// <param name="entity">The entity to test.</param>
/// <returns>True if the schedule applies to the given entity, otherwise false.</returns>
bool CanApply(Entity entity);
/// <summary>
/// Applies the schedule changes to the entity and returns the results.
/// </summary>
/// <param name="entity">The entity to update.</param>
/// <returns>The modified entity, if the changes can be applied, otherwise the same entity.</returns>
Entity Apply(Entity entity);
}

View File

@ -11,12 +11,13 @@
<ItemGroup>
<PackageReference Include="Autofac" Version="6.4.0" />
<PackageReference Include="MfGames.Gallium" Version="0.3.0" />
<PackageReference Include="MfGames.Gallium" Version="0.4.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="6.0.0" />
<PackageReference Include="Serilog" Version="2.11.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MfGames.Nitride.Temporal\MfGames.Nitride.Temporal.csproj" />
<ProjectReference Include="..\MfGames.Nitride\MfGames.Nitride.csproj" />
</ItemGroup>

View File

@ -4,6 +4,10 @@ using System.Text.RegularExpressions;
using MfGames.Gallium;
using MfGames.Nitride.Generators;
using NodaTime;
using Zio;
namespace MfGames.Nitride.Temporal.Schedules;
/// <summary>
@ -11,7 +15,7 @@ namespace MfGames.Nitride.Temporal.Schedules;
/// a starting point and calculates the information from there.
/// </summary>
[WithProperties]
public partial class NumericalPathSchedule
public partial class NumericalPathSchedule : ISchedule
{
public NumericalPathSchedule()
{
@ -21,13 +25,100 @@ public partial class NumericalPathSchedule
this.CaptureOffset = -1;
}
public Regex? PathRegex { get; set; }
public Func<Entity, string> GetPath { get; set; }
public DateTime? ScheduleStart { get; set; }
public SchedulePeriod SchedulePeriod { get; set; }
/// <summary>
/// Gets or sets the group number of the capture group with 1 being being the
/// first group in PathRegex.
/// </summary>
public int CaptureGroup { get; set; }
/// <summary>
/// Gets or sets the offset to make the resulting capture group a zero-based
/// number.
/// </summary>
public int CaptureOffset { get; set; }
/// <summary>
/// Gets or sets the method for retrieving the path for the entity.
/// </summary>
public Func<Entity, string> GetPath { get; set; }
/// <summary>
/// Gets or sets the regular expression to identify a matching path.
/// </summary>
public Regex? PathRegex { get; set; }
/// <summary>
/// Gets or sets the period between each matching entity. More precisely,
/// the schedule will be TimeSpan * (CaptureGroup + CaptureOffset).
/// </summary>
public SchedulePeriod SchedulePeriod { get; set; }
/// <summary>
/// Gets or sets when the first item is scheduled.
/// </summary>
public DateTime? ScheduleStart { get; set; }
/// <inheritdoc />
public virtual Entity Apply(
Entity entity,
Timekeeper timekeeper)
{
// Get the path and match it.
string? path = this.GetPath(entity);
Match? match = this.PathRegex?.Match(path)
?? throw new NullReferenceException(
"PathRegex was not configured for the "
+ this.GetType().Name
+ ".");
if (!match.Success)
{
return entity;
}
// Figure out the index/number of this entry.
int number = int.Parse(match.Groups[this.CaptureGroup].Value)
+ this.CaptureOffset;
// Figure out the time from the start.
TimeSpan span = this.SchedulePeriod switch
{
SchedulePeriod.Instant => TimeSpan.Zero,
SchedulePeriod.Day => TimeSpan.FromDays(1),
SchedulePeriod.Week => TimeSpan.FromDays(7),
_ => throw new InvalidOperationException(
"Cannot parse schedule period from "
+ this.SchedulePeriod
+ "."),
};
DateTime start = this.ScheduleStart
?? throw new NullReferenceException(
"Cannot use a schedule without a start date.");
DateTime when = start + span * number;
Instant instant = timekeeper.CreateInstant(when);
// If the time hasn't past, then we don't apply it.
Instant now = timekeeper.Clock.GetCurrentInstant();
return instant > now
? entity
: this.Apply(entity, number, instant);
}
/// <inheritdoc />
public virtual bool CanApply(Entity entity)
{
string? path = this.GetPath(entity);
bool match = this.PathRegex?.IsMatch(path) ?? false;
return match;
}
protected virtual Entity Apply(
Entity entity,
int number,
Instant instant)
{
return entity.Set(instant);
}
}

View File

@ -1,41 +1,180 @@
# Date Processing
# Schedules
One of the common features of static websites are blogs which leads to having
some form of date-centric processing of pages to build archive pages, calendars,
and being able to write posts in the future.
Scheduling posts and entries is fairly common process with blob posts but the
methods for creating those schedules can vary drastically because it requires
site- or project-specific elements such as which components or models to change
and how. This package provides one approach to scheduling that "applies"
changes (as described in JSON or YAML) to a given object based on the current
date (as provided by the `MfGames.Nitride.Temporal` package).
With the component system, the date of a given file is simply attached to
`Nodatime.Instant` component of the `Entity` object for the bulk of the
processing.
## Supporting Time Zones
The concept of time zones while date processing is one that is frequently
overlooked. A date is a date, right? However, most blogs and news sites have a
concept of when a new day starts but it isn't always the same time as the server
that is building the site. While a blog might be in America/Chicago time, a CI
server could be set to UTC (such as Azure build servers) and the
"day" may roll over fix or six hours before or after the blog's time.
This is why Nitride uses `Instant` for when pages are implemented. These are
points in time that are independent of time zones, but we also provide tools for
converting a date model or one from the path into a proper instant based on the
blog's time zone.
## Why NodaTime?
We decided to use [NodaTime](https://nodatime.org/) instead of the built-in date
time functions for a number of reasons, mainly because it has a more intuitive
way of handling time zones
A schedule is not needed when the date is in the path or inside a component. Using
`MfGames.Nitride.Temporal.SetFromComponent` or `MfGames.Nitride.Temporal.SetFromPath`
would be more than sufficient. This is for dynamically changing entities based on
the date based on `Timekeeper`.
## Configuring
There are two callbacks on `NitrideBuilder` that can be used to define the date
and time processing for the blog.
To use the modules, either the `NitrideTemporalSchedulesModule` can be added or
the builder extension method can be used.
```csharp
NitrideBuilder builder;
builder
.ConfigureDates((NitrideClock clock) => clock.SetTimeZone())
builder.UseTemporalSchedules();
```
## ISchedule
A schedule is a class that implements `ISchedule` which has the following methods:
- CanApply(Entity) ⟶ bool
- This returns true if the schedule can apply to the given entity.
- Apply(Entity) ⟶ Entity
- This makes the changes for the schedule on the entity and returns the results.
- If the entity doesn't apply, then it should return the entity without changes.
## ApplySchedules
The primary operation is `ApplySchedules` which take a sequence of schedule objects
and applies each one in turn against every entity given to the operation.
```csharp
IEnumerable<Entity> entities;
IList<ISchedule> schedules;
ApplySchedules op;
return op
.WithSchedules(schedules)
.Run(entities);
```
The following properties are available (along with their corresponding `With*` methods):
- `IList<ISchedule> Schedules`
- Contains the list of schedules to apply against entities.
- Defaults to an empty list.
- `Timekeeper Timekeeper`
- Used to determine when the site is being generated.
- Defaults to the one provided by `MfGames.Nitride.Temporal`.
### Numerical Path-Based Schedules
A common pattern for using schedules is to dole out a numerical series of posts over
a period of time. For example, a weekly posting of chapters or comic strips. In these
cases, there is usually a number in the path such as `chapter-01` or `comic-0244`. The
schedule starts at a certain point and then continues every day or week as needed.
The `NumericalPathSchedule` encapsulates this pattern. It uses the `UPath` component
of the entity and compares it against a regular expression that captures the numerical
part of the path. If it matches, then the schedule sets the date equal to the starting
point plus the "period" for every one past the first.
In this example:
```csharp
ApplySchedule op;
var entities = new List<Entity>
{
new Entity().Set((UPath) "chapter-01.md"),
new Entity().Set((UPath) "chapter-02.md"),
new Entity().Set((UPath) "chapter-03.md"),
};
var schedules = new List<ISchedule>
{
new NumericalPathSchedule
{
PathRegex = "chapter-(\d+),
ScheduleStart = DateTime.Parse("2023-01-01"),
SchedulePeriod = SchedulePeriod.Week,
},
}
return entities.Run(op.WithSchedules(schedules));
```
This will have chapter-01 have an `Instant` component set to 2023-01-01, the second
chapter will be set to 2023-01-08, and the third at 2023-01-15.
`NumericalPathSchedule` has the following properties:
- `Func<Entity, string> GetPath`
- An override to allow retrieving a different function.
- Defaults to `entity.Get<UPath>().ToString()`
- `Regex PathRegex`
- The regular expression that retrieves the number.
- Defaults to `^.*(\d+)` which grabs the last number found.
- `DateTime ScheduleStart`
- The date that the schedule starts.
- No default.
- `SchedulePeriod SchedulePeriod`
- Values:
- Instant (meaning everything at once, default)
- Week
- Day
- `int CaptureGroup`
- The numerical index of the capture group.
- Defaults to `1` because the first match in Regex is 1.
- `int CaptureOffset`
- The offset to make the capture group a zero-based number.
- Defaults to `-1` which makes `chapter-01` the first entry.
There is also a virtual method for applying the schedule.
- `protected Entity ApplySchedule(Entity entity, int number, Instant instant)`
- The callback method for applying the schedule to the entity.
- This defaults to `return entity.Set(instant)`.
#### Overriding Logic
The default operation of a `NumericalPathSchedule` is to only set the `Instant`
component of the entity. The class can be extended to have more site-specific
entries including adding more properties to the schedule and applying them.
```csharp
/// <summary>A model for the YAML front matter on a page.</summary>
public class PageModel
{
/// <summary>Gets or sets the access key for the page.</summary>
public string? Access { get; set; }
/// <summary>Gets or sets the optional schedule for this page.</summary>
List<PageSchedule>? Schedules { get; set; }
}
/// <summary>A schedule specific to this project.</summary>
public class PageSchedule : NumericalPathSchedule
{
public PageSchedule()
{
// Set the default to weekly.
this.SchedulePeriod = SchedulePeriod.Week;
}
/// <summary>Gets or sets the access key for the page.</summary>
public Access { get; set; }
protected override Entity Apply(Entity entity, int number, Instant instant)
{
var model = entity.Get<PageModel>();
model.Access = this.Access;
return entity.Set(instant, model);
}
}
```
Commonly, this schedule will be put into a JSON or YAML file.
```yaml
schedules:
# Patron and Ko-Fi subscribers get it all at once
- pathRegex: chapters/chapter-\d+
scheduleDate: 2025-01-01
schedulePeriod: instant # Because we overrode the default to be weekly.
access: subscribers
# The first fifteen chapters (01-15) were released at the rate of one
# per week starting in 2024. This will replace all the schedules above
# it.
- path: chapters/chapter-(0\d|1[1-5])
scheduleDate: 2030-01-01
access: public
```

View File

@ -1,79 +1,17 @@
using System;
using Autofac;
using MfGames.Nitride.Commands;
using MfGames.Nitride.Temporal.Cli;
using Serilog;
namespace MfGames.Nitride.Temporal.Schedules.Setup;
public static class NitrideTemporalSchedulesBuilderExtensions
{
/// <summary>
/// Extends the builder to allow for configuring the temporal
/// settings for generation.
/// schedules during processing.
/// </summary>
public static NitrideBuilder UseTemporal(
this NitrideBuilder builder,
Action<NitrideTemporalConfiguration>? configure = null)
public static NitrideBuilder UseTemporalSchedules(
this NitrideBuilder builder)
{
// Get the configuration so we can set the various options.
var config = new NitrideTemporalConfiguration();
configure?.Invoke(config);
// Add in the module registration.
builder.ConfigureContainer(
x =>
{
// Register the module.
x.RegisterModule<NitrideTemporalModule>();
// Add in the CLI options.
if (config.AddDateOptionToCommandLine)
{
x.RegisterType<DatePipelineCommandOption>()
.As<IPipelineCommandOption>();
}
if (config.AddExpireOptionToCommandLine
&& config.Expiration != null)
{
x.Register(
context =>
{
ILogger logger = context.Resolve<ILogger>();
Timekeeper
clock = context.Resolve<Timekeeper>();
return new ExpiresPipelineCommandOption(
logger,
clock,
config.Expiration);
})
.As<IPipelineCommandOption>();
}
});
if (config.DateTimeZone != null)
{
builder.ConfigureSite(
(
_,
scope) =>
{
ILogger logger = scope.Resolve<ILogger>();
Timekeeper timekeeper = scope.Resolve<Timekeeper>();
timekeeper.DateTimeZone = config.DateTimeZone;
logger.Verbose(
"Setting time zone to {Zone:l}",
timekeeper.DateTimeZone);
});
}
return builder;
return builder.ConfigureContainer(
x => x.RegisterModule<NitrideTemporalSchedulesModule>());
}
}

View File

@ -1,5 +1,7 @@
using Autofac;
using MfGames.Nitride.Temporal.Setup;
namespace MfGames.Nitride.Temporal.Schedules.Setup;
public class NitrideTemporalSchedulesModule : Module
@ -7,14 +9,8 @@ public class NitrideTemporalSchedulesModule : Module
/// <inheritdoc />
protected override void Load(ContainerBuilder builder)
{
builder.RegisterModule<NitrideTemporalModule>();
builder.RegisterOperators(this);
builder.RegisterValidators(this);
builder.RegisterType<Timekeeper>()
.AsSelf()
.SingleInstance();
builder.RegisterGeneric(typeof(SetInstantFromComponent<>))
.As(typeof(SetInstantFromComponent<>));
}
}

View File

@ -2,17 +2,14 @@ using FluentValidation;
namespace MfGames.Nitride.Temporal.Schedules.Validators;
public class CreateDateIndexesValidator : AbstractValidator<CreateDateIndexes>
public class ApplySchedulesValidator : AbstractValidator<ApplySchedules>
{
public CreateDateIndexesValidator()
public ApplySchedulesValidator()
{
this.RuleFor(a => a.Timekeeper)
.NotNull();
this.RuleFor(x => x.Schedules)
.NotEmpty();
this.RuleFor(a => a.CreateIndex)
.NotNull();
this.RuleFor(a => a.Formats)
this.RuleFor(x => x.Timekeeper)
.NotNull();
}
}

View File

@ -1,9 +1,11 @@
using MfGames.Nitride.Generators;
namespace MfGames.Nitride.Temporal;
/// <summary>
/// A marker component for identifying a post that can expire.
/// </summary>
public class CanExpire
[SingletonComponent]
public partial class CanExpire
{
public static CanExpire Instance { get; } = new();
}

View File

@ -11,7 +11,7 @@
<ItemGroup>
<PackageReference Include="Autofac" Version="6.4.0" />
<PackageReference Include="MfGames.Gallium" Version="0.3.0" />
<PackageReference Include="MfGames.Gallium" Version="0.4.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="6.0.0" />
<PackageReference Include="NodaTime" Version="3.1.2" />
<PackageReference Include="NodaTime.Testing" Version="3.1.2" />

View File

@ -1,6 +1,6 @@
using Autofac;
namespace MfGames.Nitride.Temporal;
namespace MfGames.Nitride.Temporal.Setup;
public class NitrideTemporalModule : Module
{

View File

@ -12,7 +12,7 @@
<ItemGroup>
<PackageReference Include="CompareNETObjects" Version="4.78.0" />
<PackageReference Include="MfGames.Gallium" Version="0.3.0" />
<PackageReference Include="MfGames.Gallium" Version="0.4.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.3.1" />
<PackageReference Include="JunitXml.TestLogger" Version="3.0.114" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.2" />
@ -25,6 +25,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="YamlDotNet" Version="12.0.0" />
</ItemGroup>
</Project>

View File

@ -3,322 +3,228 @@ using System.Collections.Generic;
using System.Linq;
using MfGames.Gallium;
using MfGames.Nitride.Temporal.Tests;
using MfGames.Nitride.Tests;
using NodaTime;
using NodaTime.Testing;
using Xunit;
using Xunit.Abstractions;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
using Zio;
namespace MfGames.Nitride.Temporal.Schedules.Tests;
public class TestPageModel
public class NumericalPathScheduleTests : TemporalSchedulesTestBase
{
public string Access { get; set; }
}
public class TestSchedule : NumericalPathSchedule
{
}
public class ApplySchedulesTests : TemporalSchedulesTestBase
{
public ApplySchedulesTests(ITestOutputHelper output)
public NumericalPathScheduleTests(ITestOutputHelper output)
: base(output)
{
}
[Fact]
public void MonthOnlyIndexes()
public void DeserializedSetupWorks()
{
using TemporalSchedulesTestContext context = this.CreateContext();
Timekeeper timekeeper = context.Resolve<Timekeeper>();
CreateDateIndexes op = context.Resolve<CreateDateIndexes>()
.WithFormats("yyyy-MM")
.WithCreateIndex(this.CreateIndex);
List<Entity> input = new()
// Create a numerical series of entities.
var input = new List<Entity>
{
new Entity().Add("page1")
.Add(timekeeper.CreateInstant(2021, 1, 2)),
new Entity().Add("page2")
.Add(timekeeper.CreateInstant(2021, 2, 2)),
new Entity().Add("page3")
.Add(timekeeper.CreateInstant(2022, 1, 2)),
new Entity().SetAll((UPath)"/chapter-01.md", new TestModel()),
new Entity().SetAll((UPath)"/chapter-02.md", new TestModel()),
new Entity().SetAll((UPath)"/chapter-03.md", new TestModel()),
};
List<Tuple<string, List<string>?, List<string>?>> actual =
this.GetActual(op, input);
TestModel model = new DeserializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.Build()
.Deserialize<TestModel>(
string.Join(
"\n",
"---",
"access: custom",
"schedules:",
" - scheduleStart: 2023-01-01",
" access: public",
""));
List<TestSchedule>? schedules = model.Schedules!;
var expected = new List<Tuple<string, List<string>?, List<string>?>>
{
new(
"index-2021-01",
new List<string> { "page1" },
new List<string>()),
new(
"index-2021-02",
new List<string> { "page2" },
new List<string>()),
new(
"index-2022-01",
new List<string> { "page3" },
new List<string>()),
new("page1", null, null),
new("page2", null, null),
new("page3", null, null),
};
// Create the operation and run it, but treat it as being set after the
// second but before the third item.
Timekeeper time = context.Resolve<Timekeeper>();
ApplySchedules op = context.Resolve<ApplySchedules>()
.WithSchedules(schedules);
var now = Instant.FromUtc(2023, 1, 9, 0, 0);
TestHelper.CompareObjects(expected, actual);
}
time.Clock = new FakeClock(now);
[Fact]
public void YearMonthDayIndexes()
{
using TemporalSchedulesTestContext context = this.CreateContext();
Timekeeper timekeeper = context.Resolve<Timekeeper>();
CreateDateIndexes op = context.Resolve<CreateDateIndexes>()
.WithFormats("yyyy/MM/dd", "yyyy/MM", "yyyy")
.WithCreateIndex(this.CreateIndex);
List<Entity> input = new()
{
new Entity().Add("page1")
.Add(timekeeper.CreateInstant(2021, 1, 2)),
new Entity().Add("page2")
.Add(timekeeper.CreateInstant(2021, 2, 2)),
new Entity().Add("page3")
.Add(timekeeper.CreateInstant(2022, 1, 2)),
};
List<Tuple<string, List<string>?, List<string>?>> actual =
this.GetActual(op, input);
var expected = new List<Tuple<string, List<string>?, List<string>?>>
{
new(
"index-2021",
new List<string>(),
new List<string> { "index-2021/01", "index-2021/02" }),
new(
"index-2021/01",
new List<string>(),
new List<string> { "index-2021/01/02" }),
new(
"index-2021/01/02",
new List<string> { "page1" },
new List<string>()),
new(
"index-2021/02",
new List<string>(),
new List<string> { "index-2021/02/02" }),
new(
"index-2021/02/02",
new List<string> { "page2" },
new List<string>()),
new(
"index-2022",
new List<string>(),
new List<string> { "index-2022/01" }),
new(
"index-2022/01",
new List<string>(),
new List<string> { "index-2022/01/02" }),
new(
"index-2022/01/02",
new List<string> { "page3" },
new List<string>()),
new("page1", null, null),
new("page2", null, null),
new("page3", null, null),
};
TestHelper.CompareObjects(expected, actual);
}
[Fact]
public void YearMonthDayIndexesThreshold1()
{
using TemporalSchedulesTestContext context = this.CreateContext();
Timekeeper timekeeper = context.Resolve<Timekeeper>();
CreateDateIndexes op = context.Resolve<CreateDateIndexes>()
.WithFormats("yyyy/MM/dd", "yyyy/MM", "yyyy")
.WithCreateIndex(this.CreateIndex)
.WithLessThanEqualCollapse(1);
List<Entity> input = new()
{
new Entity().Add("page1")
.Add(timekeeper.CreateInstant(2021, 1, 2)),
new Entity().Add("page2")
.Add(timekeeper.CreateInstant(2021, 2, 2)),
new Entity().Add("page3")
.Add(timekeeper.CreateInstant(2022, 1, 2)),
};
List<Tuple<string, List<string>?, List<string>?>> actual =
this.GetActual(op, input);
var expected = new List<Tuple<string, List<string>?, List<string>?>>
{
new(
"index-2021",
new List<string>(),
new List<string> { "index-2021/01", "index-2021/02" }),
new(
"index-2021/01",
new List<string> { "page1" },
new List<string> { "index-2021/01/02" }),
new(
"index-2021/01/02",
new List<string> { "page1" },
new List<string>()),
new(
"index-2021/02",
new List<string> { "page2" },
new List<string> { "index-2021/02/02" }),
new(
"index-2021/02/02",
new List<string> { "page2" },
new List<string>()),
new(
"index-2022",
new List<string> { "page3" },
new List<string> { "index-2022/01" }),
new(
"index-2022/01",
new List<string> { "page3" },
new List<string> { "index-2022/01/02" }),
new(
"index-2022/01/02",
new List<string> { "page3" },
new List<string>()),
new("page1", null, null),
new("page2", null, null),
new("page3", null, null),
};
TestHelper.CompareObjects(expected, actual);
}
[Fact]
public void YearMonthIndexes()
{
using TemporalSchedulesTestContext context = this.CreateContext();
Timekeeper timekeeper = context.Resolve<Timekeeper>();
CreateDateIndexes op = context.Resolve<CreateDateIndexes>()
.WithFormats("yyyy-MM", "yyyy")
.WithCreateIndex(this.CreateIndex);
List<Entity> input = new()
{
new Entity().Add("page1")
.Add(timekeeper.CreateInstant(2021, 1, 2)),
new Entity().Add("page2")
.Add(timekeeper.CreateInstant(2021, 2, 2)),
new Entity().Add("page3")
.Add(timekeeper.CreateInstant(2022, 1, 2)),
};
List<Tuple<string, List<string>?, List<string>?>> actual =
this.GetActual(op, input);
var expected = new List<Tuple<string, List<string>?, List<string>?>>
{
new(
"index-2021",
new List<string>(),
new List<string> { "index-2021-01", "index-2021-02" }),
new(
"index-2021-01",
new List<string> { "page1" },
new List<string>()),
new(
"index-2021-02",
new List<string> { "page2" },
new List<string>()),
new(
"index-2022",
new List<string>(),
new List<string> { "index-2022-01" }),
new(
"index-2022-01",
new List<string> { "page3" },
new List<string>()),
new("page1", null, null),
new("page2", null, null),
new("page3", null, null),
};
TestHelper.CompareObjects(expected, actual);
}
[Fact]
public void YearOnlyIndexes()
{
using TemporalSchedulesTestContext context = this.CreateContext();
Timekeeper timekeeper = context.Resolve<Timekeeper>();
CreateDateIndexes op = context.Resolve<CreateDateIndexes>()
.WithFormats("yyyy")
.WithCreateIndex(this.CreateIndex);
List<Entity> input = new()
{
new Entity().Add("page1")
.Add(timekeeper.CreateInstant(2021, 1, 2)),
new Entity().Add("page2")
.Add(timekeeper.CreateInstant(2021, 2, 2)),
new Entity().Add("page3")
.Add(timekeeper.CreateInstant(2022, 1, 2)),
};
List<Tuple<string, List<string>?, List<string>?>> actual =
this.GetActual(op, input);
var expected = new List<Tuple<string, List<string>?, List<string>?>>
{
new(
"index-2021",
new List<string> { "page1", "page2" },
new List<string>()),
new("index-2022", new List<string> { "page3" }, new List<string>()),
new("page1", null, null),
new("page2", null, null),
new("page3", null, null),
};
TestHelper.CompareObjects(expected, actual);
}
private Entity CreateIndex(DateIndex a)
{
return new Entity().Add(a)
.Add($"index-{a.Key}");
}
private List<Tuple<string, List<string>?, List<string>?>> GetActual(
CreateDateIndexes op,
List<Entity> input)
{
var actual = op.Run(input)
var actual = op
.Run(input)
.Select(
x => new Tuple<string, List<string>?, List<string>?>(
x.Get<string>(),
x.GetOptional<DateIndex>()
?.Entries.Select(a => a.Get<string>())
.OrderBy(b => b)
.ToList(),
x.GetOptional<DateIndex>()
?.Indexes.Select(a => a.Get<string>())
.OrderBy(b => b)
.ToList()))
.OrderBy(x => x.Item1)
a => string.Format(
"{0} -- {1} -- {2}",
a.Get<UPath>().ToString(),
a.Has<Instant>()
? time
.ToDateTime(a.Get<Instant>())
.ToString("yyyy-MM-dd")
: "none",
a.Get<TestModel>().Access))
.ToList();
return actual;
var expected = new List<string>
{
"/chapter-01.md -- 2023-01-01 -- public",
"/chapter-02.md -- 2023-01-08 -- public",
"/chapter-03.md -- none -- private",
};
TestHelper.CompareObjects(expected, actual);
}
[Fact]
public void ManualSetupWorks()
{
using TemporalSchedulesTestContext context = this.CreateContext();
// Create a numerical series of entities.
var input = new List<Entity>
{
new Entity().SetAll((UPath)"/chapter-01.md", new TestModel()),
new Entity().SetAll((UPath)"/chapter-02.md", new TestModel()),
new Entity().SetAll((UPath)"/chapter-03.md", new TestModel()),
};
var schedules = new List<TestSchedule>
{
new()
{
ScheduleStart = DateTime.Parse("2023-01-01"),
Access = "public",
},
};
// Create the operation and run it, but treat it as being set after the
// second but before the third item.
Timekeeper time = context.Resolve<Timekeeper>();
ApplySchedules op = context.Resolve<ApplySchedules>()
.WithSchedules(schedules);
var now = Instant.FromUtc(2023, 1, 9, 0, 0);
time.Clock = new FakeClock(now);
var actual = op
.Run(input)
.Select(
a => string.Format(
"{0} -- {1} -- {2}",
a.Get<UPath>().ToString(),
a.Has<Instant>()
? time
.ToDateTime(a.Get<Instant>())
.ToString("yyyy-MM-dd")
: "none",
a.Get<TestModel>().Access))
.ToList();
var expected = new List<string>
{
"/chapter-01.md -- 2023-01-01 -- public",
"/chapter-02.md -- 2023-01-08 -- public",
"/chapter-03.md -- none -- private",
};
TestHelper.CompareObjects(expected, actual);
}
[Fact]
public void SequencedScheduleWorks()
{
using TemporalSchedulesTestContext context = this.CreateContext();
// Create a numerical series of entities.
var input = new List<Entity>
{
new Entity().SetAll((UPath)"/chapter-01.md", new TestModel()),
new Entity().SetAll((UPath)"/chapter-02.md", new TestModel()),
new Entity().SetAll((UPath)"/chapter-03.md", new TestModel()),
};
var schedules = new List<TestSchedule>
{
new()
{
ScheduleStart = DateTime.Parse("2023-01-01"),
Access = "subscriber",
},
new()
{
ScheduleStart = DateTime.Parse("2023-01-07"),
Access = "public",
},
};
// Create the operation and run it, but treat it as being set after the
// second but before the third item.
Timekeeper time = context.Resolve<Timekeeper>();
ApplySchedules op = context.Resolve<ApplySchedules>()
.WithSchedules(schedules);
var now = Instant.FromUtc(2023, 1, 9, 0, 0);
time.Clock = new FakeClock(now);
var actual = op
.Run(input)
.Select(
a => string.Format(
"{0} -- {1} -- {2}",
a.Get<UPath>().ToString(),
a.Has<Instant>()
? time
.ToDateTime(a.Get<Instant>())
.ToString("yyyy-MM-dd")
: "none",
a.Get<TestModel>().Access))
.ToList();
var expected = new List<string>
{
"/chapter-01.md -- 2023-01-07 -- public",
"/chapter-02.md -- 2023-01-08 -- subscriber",
"/chapter-03.md -- none -- private",
};
TestHelper.CompareObjects(expected, actual);
}
public class TestModel
{
public string? Access { get; set; } = "private";
public List<TestSchedule>? Schedules { get; set; }
}
public class TestSchedule : NumericalPathSchedule
{
public TestSchedule()
{
this.SchedulePeriod = SchedulePeriod.Week;
}
public string? Access { get; set; }
/// <inheritdoc />
protected override Entity Apply(
Entity entity,
int number,
Instant instant)
{
TestModel model = entity.Get<TestModel>();
model.Access = this.Access;
return entity.SetAll(instant, model);
}
}
}

View File

@ -4,7 +4,8 @@ using Xunit.Abstractions;
namespace MfGames.Nitride.Temporal.Schedules.Tests;
public abstract class TemporalSchedulesTestBase : TestBase<TemporalTestContext>
public abstract class TemporalSchedulesTestBase
: TestBase<TemporalSchedulesTestContext>
{
protected TemporalSchedulesTestBase(ITestOutputHelper output)
: base(output)

View File

@ -1,16 +1,18 @@
using Autofac;
using MfGames.Nitride.Temporal.Schedules.Setup;
using MfGames.Nitride.Temporal.Setup;
using MfGames.Nitride.Tests;
namespace MfGames.Nitride.Temporal.Schedules.Tests;
public class TemporalTestContext : NitrideTestContext
public class TemporalSchedulesTestContext : NitrideTestContext
{
/// <inheritdoc />
protected override void ConfigureContainer(ContainerBuilder builder)
{
base.ConfigureContainer(builder);
builder.RegisterModule<NitrideTemporalModule>();
builder.RegisterModule<NitrideTemporalSchedulesModule>();
}
}

View File

@ -12,7 +12,7 @@
<ItemGroup>
<PackageReference Include="CompareNETObjects" Version="4.78.0" />
<PackageReference Include="MfGames.Gallium" Version="0.3.0" />
<PackageReference Include="MfGames.Gallium" Version="0.4.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.3.1" />
<PackageReference Include="JunitXml.TestLogger" Version="3.0.114" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.2" />

View File

@ -1,5 +1,6 @@
using Autofac;
using MfGames.Nitride.Temporal.Setup;
using MfGames.Nitride.Tests;
namespace MfGames.Nitride.Temporal.Tests;