diff --git a/.gitignore b/.gitignore
index f34ab01..2fe225e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,4 +5,6 @@ riderModule.iml
/_ReSharper.Caches/
# Don't care about IDE specific things
/.idea
-*.DotSettings.user
\ No newline at end of file
+*.DotSettings.user
+# Don't care about files created from the build script
+output/
\ No newline at end of file
diff --git a/Directory.Packages.props b/Directory.Packages.props
new file mode 100644
index 0000000..1e8aa77
--- /dev/null
+++ b/Directory.Packages.props
@@ -0,0 +1,17 @@
+
+
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/PowershellModule.slnx b/PowershellModule.slnx
new file mode 100644
index 0000000..04cf69b
--- /dev/null
+++ b/PowershellModule.slnx
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/build.ps1 b/build.ps1
new file mode 100644
index 0000000..a707719
--- /dev/null
+++ b/build.ps1
@@ -0,0 +1,2 @@
+dotnet run --project build/Build.csproj -- $args
+exit $LASTEXITCODE;
\ No newline at end of file
diff --git a/build.sh b/build.sh
new file mode 100644
index 0000000..dfd6b85
--- /dev/null
+++ b/build.sh
@@ -0,0 +1 @@
+dotnet run --project ./build/Build.csproj -- "$@"
diff --git a/build/Build.csproj b/build/Build.csproj
new file mode 100644
index 0000000..0d0955f
--- /dev/null
+++ b/build/Build.csproj
@@ -0,0 +1,13 @@
+
+
+ Exe
+ net10.0
+ $(MSBuildProjectDirectory)
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/build/Program.cs b/build/Program.cs
new file mode 100644
index 0000000..ffb3738
--- /dev/null
+++ b/build/Program.cs
@@ -0,0 +1,55 @@
+using Cake.Common;
+using Cake.Common.IO;
+using Cake.Common.IO.Paths;
+using Cake.Core;
+using Cake.Frosting;
+using Cake.Powershell;
+
+public static class Program
+{
+ public static int Main(string[] args)
+ {
+ return new CakeHost()
+ .UseContext()
+ .Run(args);
+ }
+}
+
+public class BuildContext : FrostingContext
+{
+ ///
+ /// Base source directory
+ ///
+ public ConvertableDirectoryPath BaseSourceLocation { get; set; }
+
+ ///
+ /// Powershell module project folder
+ ///
+ public ConvertableDirectoryPath PowershellModuleProjectDirectory { get; set; }
+
+ ///
+ /// Powershell module csproj location
+ ///
+ public ConvertableFilePath PowershellModuleCsproj { get; set; }
+
+ ///
+ /// Output directory for built DLLs and powershell module manifest files
+ ///
+ public ConvertableDirectoryPath PowershellModuleOutputDir { get; set; }
+
+ ///
+ /// Path to powershell script that creates the module manifest files
+ ///
+ public ConvertableFilePath CreateModuleManifestScript { get; set; }
+
+ public BuildContext(ICakeContext context)
+ : base(context)
+ {
+ BaseSourceLocation = context.Directory("../src");
+ PowershellModuleProjectDirectory = BaseSourceLocation + context.Directory("PowershellModule");
+ PowershellModuleCsproj = PowershellModuleProjectDirectory + context.File("PowershellModule.csproj");
+ PowershellModuleOutputDir = context.Directory("../") + context.Directory("output") + context.Directory("PowershellModule");
+ var buildScriptDirectory = context.Directory("./Scripts");
+ CreateModuleManifestScript = buildScriptDirectory + context.File("CreateModuleManifest.ps1");
+ }
+}
\ No newline at end of file
diff --git a/build/Scripts/CreateModuleManifest.ps1 b/build/Scripts/CreateModuleManifest.ps1
new file mode 100644
index 0000000..4e3d920
--- /dev/null
+++ b/build/Scripts/CreateModuleManifest.ps1
@@ -0,0 +1,24 @@
+param (
+ [string]$path,
+ [string]$guid,
+ [string]$author,
+ [string[]]$nestedModules,
+ [string]$rootModule,
+ [string[]]$cmdletsToExport,
+ [string]$manifestFileLocation
+)
+
+$manifestSplat = @{
+ Path = "$path"
+ GUID = "$guid"
+ Author = "$author"
+ NestedModules = @($nestedModules)
+ RootModule = "$rootModule"
+ CmdletsToExport = @($cmdletsToExport)
+ FunctionsToExport = @()
+ VariablesToExport = @()
+ AliasesToExport = @()
+}
+
+New-ModuleManifest @manifestSplat
+New-Item "$manifestFileLocation" -ItemType File
\ No newline at end of file
diff --git a/build/Tasks/BuildTask.cs b/build/Tasks/BuildTask.cs
new file mode 100644
index 0000000..c731bcb
--- /dev/null
+++ b/build/Tasks/BuildTask.cs
@@ -0,0 +1,36 @@
+using Cake.Common.Tools.DotNet;
+using Cake.Common.Tools.DotNet.Build;
+using Cake.Common.Tools.DotNet.MSBuild;
+using Cake.Core.Diagnostics;
+using Cake.Frosting;
+
+namespace Build.Tasks;
+
+[TaskName("Build")]
+[IsDependentOn(typeof(CleanTask))]
+[IsDependeeOf(typeof(CopyOutputTask))]
+public class BuildTask : FrostingTask
+{
+ public override void Run(BuildContext context)
+ {
+ context.Log.Information($"Building: {context.PowershellModuleCsproj}");
+
+ var buildSettings = new DotNetBuildSettings()
+ {
+ MSBuildSettings = new DotNetMSBuildSettings()
+ {
+ VersionSuffix = "cake"
+ },
+ OutputDirectory = context.PowershellModuleOutputDir,
+ DiagnosticOutput = true
+ };
+
+ // /p:DebugSymbols=false
+ buildSettings.MSBuildSettings.Properties.Add("DebugSymbols", ["false"]);
+ // /p:DebugType=None
+ buildSettings.MSBuildSettings.Properties.Add("DebugType", ["None"]);
+
+ context.DotNetBuild(context.PowershellModuleCsproj, buildSettings);
+ base.Run(context);
+ }
+}
\ No newline at end of file
diff --git a/build/Tasks/CleanTask.cs b/build/Tasks/CleanTask.cs
new file mode 100644
index 0000000..36ab859
--- /dev/null
+++ b/build/Tasks/CleanTask.cs
@@ -0,0 +1,23 @@
+using Cake.Common;
+using Cake.Common.Tools.DotNet;
+using Cake.Core.Diagnostics;
+using Cake.Frosting;
+
+namespace Build.Tasks;
+
+[TaskName("Clean")]
+public class CleanTask : FrostingTask
+{
+ public override void Run(BuildContext context)
+ {
+ context.DotNetClean(context.PowershellModuleProjectDirectory);
+
+ var outDir = context.FileSystem.GetDirectory(context.PowershellModuleOutputDir);
+ if (outDir.Exists)
+ {
+ outDir.Delete(true);
+ }
+
+ base.Run(context);
+ }
+}
\ No newline at end of file
diff --git a/build/Tasks/CopyOutputTask.cs b/build/Tasks/CopyOutputTask.cs
new file mode 100644
index 0000000..175f82a
--- /dev/null
+++ b/build/Tasks/CopyOutputTask.cs
@@ -0,0 +1,49 @@
+using System;
+using System.Linq;
+using Cake.Core.Diagnostics;
+using Cake.Core.IO;
+using Cake.Frosting;
+using Cake.Powershell;
+
+namespace Build.Tasks;
+
+[TaskName("CopyOutput")]
+public class CopyOutputTask : FrostingTask
+{
+ public override void Run(BuildContext context)
+ {
+ var powershellModuleName = "PowershellModule";
+ var scriptParams = new
+ {
+ Path = $"{context.PowershellModuleOutputDir}/{powershellModuleName}.psd1",
+ Guid = Guid.Parse("5cdf4635-edb0-428c-8d9b-92d0bcd47443"),
+ Author = "Me",
+ NestedModules = new[] { $"{powershellModuleName}.dll" },
+ RootModule = $"{powershellModuleName}.psm1",
+ CmdletsToExport = new[] { "Get-Calendar" },
+ ManifestFileLocation = $"{context.PowershellModuleOutputDir}/{powershellModuleName}.psm1"
+ };
+
+ var psSettings = new PowershellSettings()
+ {
+ Arguments = new ProcessArgumentBuilder(),
+ };
+
+ // This feels a bit ugly/redundant seeing as I've defined the scriptParams above, but I'm leaving it as is
+ // until I want to come back and clean this up properly
+ psSettings.Arguments.Append("path", ToPowershellSafeString(scriptParams.Path));
+ psSettings.Arguments.Append("guid", ToPowershellSafeString(scriptParams.Guid.ToString()));
+ psSettings.Arguments.Append("author", ToPowershellSafeString(scriptParams.Author));
+ psSettings.Arguments.Append("nestedModules", $"@({string.Join(",", scriptParams.NestedModules.Select(ToPowershellSafeString))})");
+ psSettings.Arguments.Append("rootModule", ToPowershellSafeString(scriptParams.RootModule));
+ psSettings.Arguments.Append("cmdletsToExport", $"@({string.Join(",", scriptParams.CmdletsToExport.Select(ToPowershellSafeString))})");
+ psSettings.Arguments.Append("manifestFileLocation", ToPowershellSafeString(scriptParams.ManifestFileLocation));
+
+ context.StartPowershellFile(context.CreateModuleManifestScript, psSettings);
+ context.Log.Information($"Module output to: {context.PowershellModuleOutputDir.Path.MakeAbsolute(context.Environment)}");
+
+ base.Run(context);
+ }
+
+ private static string ToPowershellSafeString(string unescapedString) => $"'{unescapedString}'";
+}
\ No newline at end of file
diff --git a/build/Tasks/DefaultTask.cs b/build/Tasks/DefaultTask.cs
new file mode 100644
index 0000000..200768c
--- /dev/null
+++ b/build/Tasks/DefaultTask.cs
@@ -0,0 +1,16 @@
+using Cake.Core;
+using Cake.Core.Diagnostics;
+using Cake.Frosting;
+
+namespace Build.Tasks;
+
+// Consider this the "entry" point for builds, task order is defined by a chain of IsDependentOn
+[TaskName("Default")]
+[IsDependentOn(typeof(CopyOutputTask))]
+public class DefaultTask : FrostingTask
+{
+ public override void Run(ICakeContext context)
+ {
+ base.Run(context);
+ }
+}
\ No newline at end of file
diff --git a/src/ModuleCore/Calendar/CalendarGenerator.cs b/src/ModuleCore/Calendar/CalendarGenerator.cs
new file mode 100644
index 0000000..8494fc9
--- /dev/null
+++ b/src/ModuleCore/Calendar/CalendarGenerator.cs
@@ -0,0 +1,261 @@
+using System.Globalization;
+using System.Text;
+
+namespace ModuleCore.Calendar;
+
+public sealed class CalendarGenerator
+{
+ public const string DefaultMarkedOffSymbol = "x";
+ public const DayOfWeek DefaultStartOfWeek = DayOfWeek.Monday;
+
+ private readonly string _markedOffSymbol;
+ private readonly DayOfWeek _startOfWeek;
+
+ ///
+ /// Holds configured day abbreviations, based on .
+ ///
+ /// Using english as the culture: Sunday = Sun, Monday = Mon and so on. This lookup also starts with
+ /// Sunday as the first day to match the enum starting with Sunday.
+ ///
+ ///
+ private readonly List _daysOfWeekLookup;
+
+ ///
+ /// Used to work out how to pad columns for each day, based on the longest abbreviated day
+ ///
+ private readonly int _dayWidth;
+
+ private readonly CultureInfo _cultureInfo;
+
+ private readonly string _rolloverDaySymbol = "";
+
+ public CalendarGenerator(string? markedDaySymbol = null, DayOfWeek? startOfWeek = null)
+ {
+ _markedOffSymbol = markedDaySymbol ?? DefaultMarkedOffSymbol;
+ _startOfWeek = startOfWeek ?? DefaultStartOfWeek;
+
+ // Generate localised day of week lookup table
+ _daysOfWeekLookup = new List(7);
+ _dayWidth = _markedOffSymbol.Length;
+ _cultureInfo = CultureInfo.CurrentCulture;
+ for (var i = 0; i < 7; i++)
+ {
+ var localisedDayAbbreviation = _cultureInfo.DateTimeFormat.GetAbbreviatedDayName((DayOfWeek)i);
+
+ // For unicode languages (such as Japanese and Chinese), the length of a day will be reported as 1,
+ // but will display in a way that makes them look short or offset.
+ // eg:
+ // ┌───────────────────────────┐
+ // │ 7月 │
+ // ├───┬───┬───┬───┬───┬───┬───┤
+ // │ 月 │ 火 │ 水 │ 木 │ 金 │ 土 │ 日 │
+ // ├───┴───┴───┴───┴───┴───┴───┤
+ // There's not really an easy fix I know of (yet) to resolve this (and I'm not really too bothered by it yet)
+ // so unfortunately it has to stay as a known bug as is currently
+ if (localisedDayAbbreviation.Length > _dayWidth)
+ {
+ _dayWidth = localisedDayAbbreviation.Length;
+ }
+
+ _daysOfWeekLookup.Add(localisedDayAbbreviation);
+ }
+
+ // Increase the width by 2 to include a space on either side
+ _dayWidth += 2;
+ }
+
+ ///
+ /// Renders the configured calendar for the given datetime
+ ///
+ ///
+ /// Defaults to , used to display elapsed days
+ ///
+ public string Render(DateTime month, DateTime? todayOverride = null)
+ {
+ var sb = new StringBuilder();
+ // Generate the header for the month/days of week
+ GenerateHeader(sb, month);
+
+ var calendarForMonth = GenerateCalendarForMonth(month, _markedOffSymbol, _startOfWeek, _rolloverDaySymbol, todayOverride ?? DateTime.Now);
+
+ for (var i = 0; i < calendarForMonth.WeeksInMonth; i++)
+ {
+ sb.Append("│");
+ for (var j = 0; j < 7; j++)
+ {
+ var index = j + i * 7;
+ // We align days to the right of their box, 1 off from the border.
+ // Sure I could center them, but honestly this is better to read.
+ sb.Append(calendarForMonth.Calendar[index].PadLeft(_dayWidth - 1));
+ sb.Append(" │");
+ }
+
+ sb.AppendLine();
+ }
+
+ // Cap the calendar off
+ GenerateSpacer(sb, "└", "─", "┴", "┘", true);
+
+ return sb.ToString();
+ }
+
+ private void GenerateHeader(StringBuilder sb, DateTime month)
+ {
+ // Top border for the calendar, with padder and seperator the same as there's no columns immediately
+ // under it
+ GenerateSpacer(sb, "┌", "─", "─", "┐");
+
+ var monthString = month.ToString("MMMM yyyy", _cultureInfo);
+ // total width of the calendar is width of the localised day calculated in the constructor, plus 7 extra
+ // to account for separators for each day.
+ // It shouldn't need to be said that the 7 here stands for days in a week
+ var calendarWidth = _dayWidth * 7 + 7;
+ // Calculate the offsets for
+ var offsets = (
+ left: Math.Floor(calendarWidth / 2.0 + monthString.Length / 2.0),
+ right: Math.Ceiling(calendarWidth / 2.0 - monthString.Length / 2.0)
+ );
+
+ sb.Append("│")
+ .Append(monthString.PadLeft((int)offsets.left))
+ .AppendLine("│".PadLeft((int)offsets.right));
+
+ // Top border for the week
+ GenerateSpacer(sb, "├", "─", "┬", "┤");
+
+ // Work out the week display based on start of week
+ var d = 0;
+ do
+ {
+ // Index into the day of week, wrapping if needed from the configured start of the week.
+ // The DayOfWeek enum starts with Sunday, so by default the start of the week is 0,
+ // But most calendars visually start with Monday as the start of the week which is our default start
+ // of week.
+ // We also pad the string to the width of the day (minus 2 for spaces either side of the day), if
+ // the marked day symbol is a long string.
+ // Why did I decide to support arbitrary length marked days? Why not?
+ sb.Append($"│ {_daysOfWeekLookup[(d + (int)_startOfWeek) % 7].PadLeft(_dayWidth - 2)} ");
+ d++;
+ } while (d < 7);
+
+ // End the week block
+ sb.AppendLine("│");
+ // bottom border
+ GenerateSpacer(sb, "├", "─", "┼", "┤");
+ }
+
+ private void GenerateSpacer(StringBuilder sb, string leftCap, string padder, string seperator, string rightCap, bool noNewLine = false)
+ {
+ sb.Append(leftCap);
+ for (var i = 0; i < 7; i++)
+ {
+ for (var j = 0; j < _dayWidth; j++)
+ {
+ sb.Append(padder);
+ }
+
+ if (i < 6)
+ {
+ sb.Append(seperator);
+ }
+ }
+
+ if (noNewLine)
+ {
+ sb.Append(rightCap);
+ }
+ else
+ {
+ sb.AppendLine(rightCap);
+ }
+ }
+
+ private MonthCalendar GenerateCalendarForMonth(DateTime month, string markedOffSymbol, DayOfWeek calendarStartOfWeek, string rolloverDaySymbol, DateTime today)
+ {
+ // TODO: use startOfWeek to offset the start of the calendar later
+ // TODO: I think I meant the calendar name header?
+
+ // Reset the month to the start
+ var startOfMonth = new DateTime(month.Year, month.Month, 1);
+ // If the visual start of the month is before the actual start day of the month, we need to visually shift the
+ // calendar "back" a week, and pad appropriately.
+ // Eg, if the start of the month is Sunday, but our visual start of the week is a Monday, then we need to
+ // make a new week to display Monday as the start, and then pad blank days to Sunday.
+ // This could probably be calculated better to avoid annoying to read logic but I'm drunk at this moment so the
+ // brain ain't there.
+ // For what its worth, I wasn't drunk for the rest of the code in this file, as hard as that is to believe!
+ var daysToPadToStartMonth = startOfMonth.DayOfWeek >= calendarStartOfWeek
+ ? startOfMonth.DayOfWeek - calendarStartOfWeek
+ : 7 - (calendarStartOfWeek - startOfMonth.DayOfWeek);
+
+ var daysInMonth = DateTime.DaysInMonth(month.Year, month.Month);
+ // TODO: a lot of this could probably be simplified by simply taking the start day of the week, and then
+ // based on where it would be in the first week, work out if the month is 4, 5 or 6 weeks from that.
+ // all this needs is a pre-calculated table and that's it.
+ // Weeks have 7 days so there's only a fixed permutation of weeks based on the day of the week a month
+ // starts on, and months have anywhere from 28 to 31 days
+ // Increase the days by the previous months
+ var paddedDaysInMonth = daysInMonth + daysToPadToStartMonth;
+ // How many days to add to the end to make the absolute number of days (including any
+ // days that would visually roll over on a calendar)
+ // eg, if there are 2 "roll over" days from the previous month and this month has 31 days,
+ // Work out how many days we have left from 7, then get the _final_ number of days to make it a multiple
+ // _of_ 7.
+ // So this results in (33%7) == 5 - 7 == 2 + 33 == 35 % 7 == 0 which means we have a "complete" month
+ // including next month roll over.
+ // Visually we won't be showing those roll over days but it makes things easier if I decide to later.
+ // If the days in the month plus padded days is already a multiple of 7, we don't need to add any more days.
+ var daysToPadToEndMonth = paddedDaysInMonth % 7 != 0
+ ? 7 - paddedDaysInMonth % 7
+ : 0;
+ paddedDaysInMonth += daysToPadToEndMonth;
+ // Round to the nearest week after padding for the previous months days.
+ // Naively allowing a loss of precision via int division because the paddedDaysInMonth should always
+ // be a multiple of 7.
+ // "should always" is going to be found not as true later
+ var weeksInMonth = paddedDaysInMonth / 7;
+
+ var cal = new List();
+ for (var i = 0; i < daysToPadToStartMonth; i++)
+ {
+ cal.Add(rolloverDaySymbol);
+ }
+
+ // If we're in the same year and month, any days already elapsed should be marked off,
+ // otherwise we display all days.
+ // If the DateTime we're rendering is in the past we render all days as marked off
+ // TODO: maybe don't do that and display the calendar differently
+ var hideElapsedDays = today.Year == month.Year && today.Month == month.Month;
+
+ for (var i = 1; i < daysInMonth + 1; i++)
+ {
+ // if we're marking off elapsed days and we're not today or ahead, 'mark' it off
+ if (hideElapsedDays && i < today.Day)
+ {
+ cal.Add(markedOffSymbol);
+ }
+ else
+ {
+ cal.Add($"{i}");
+ }
+ }
+
+ // pad the rollover days at the end of the calendar
+ for (var i = 0; i < daysToPadToEndMonth; i++)
+ {
+ cal.Add(rolloverDaySymbol);
+ }
+
+ return new MonthCalendar
+ {
+ Calendar = cal,
+ WeeksInMonth = weeksInMonth
+ };
+ }
+}
+
+public class MonthCalendar
+{
+ public List Calendar { get; set; } = [];
+ public int WeeksInMonth { get; set; }
+}
\ No newline at end of file
diff --git a/src/ModuleCore/Directory.Build.props b/src/ModuleCore/Directory.Build.props
new file mode 100644
index 0000000..afa6e9c
--- /dev/null
+++ b/src/ModuleCore/Directory.Build.props
@@ -0,0 +1,6 @@
+
+
+ 0.0.0
+ dev
+
+
\ No newline at end of file
diff --git a/src/ModuleCore/ModuleCore.csproj b/src/ModuleCore/ModuleCore.csproj
new file mode 100644
index 0000000..4ef1a45
--- /dev/null
+++ b/src/ModuleCore/ModuleCore.csproj
@@ -0,0 +1,10 @@
+
+
+
+ net10.0
+ enable
+ enable
+ latestmajor
+
+
+
diff --git a/src/ModuleHarness/ModuleHarness.csproj b/src/ModuleHarness/ModuleHarness.csproj
new file mode 100644
index 0000000..9fe2c2b
--- /dev/null
+++ b/src/ModuleHarness/ModuleHarness.csproj
@@ -0,0 +1,15 @@
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+ latestmajor
+
+
+
+
+
+
+
diff --git a/src/ModuleHarness/Program.cs b/src/ModuleHarness/Program.cs
new file mode 100644
index 0000000..d314f9d
--- /dev/null
+++ b/src/ModuleHarness/Program.cs
@@ -0,0 +1,20 @@
+using System.Text;
+using ModuleCore.Calendar;
+
+namespace ModuleHarness;
+
+class Program
+{
+ static void Main(string[] args)
+ {
+ Console.OutputEncoding = Encoding.Unicode;
+ var a = new CalendarGenerator();
+ var lastMonth = a.Render(DateTime.Now.AddMonths(-1));
+ var now = a.Render(DateTime.Now);
+ var august = a.Render(DateTime.Now.AddMonths(1));
+
+ Console.Write(lastMonth);
+ Console.Write(now);
+ Console.Write(august);
+ }
+}
\ No newline at end of file
diff --git a/src/PowershellHarness/CustomHost.cs b/src/PowershellHarness/CustomHost.cs
new file mode 100644
index 0000000..96ef22b
--- /dev/null
+++ b/src/PowershellHarness/CustomHost.cs
@@ -0,0 +1,216 @@
+using System.Collections.ObjectModel;
+using System.Globalization;
+using System.Management.Automation;
+using System.Management.Automation.Host;
+using System.Security;
+using System.Text;
+
+namespace PowershellHarness;
+
+// A whole bunch of empty implementations just so cmdlets have access to anything within Host.Ui (and probably any cmdlets
+// that prompt for information later)
+// https://github.com/leechristensen/OffensivePowerShellTasking/blob/master/OffensivePowerShellTasking/CustomPSHost.cs#L13
+public class CustomHost : PSHost
+{
+ public override string Name => "Custom Host";
+ public override Version Version { get; } = new Version(0, 0, 0, 0);
+ public override Guid InstanceId { get; } = Guid.NewGuid();
+
+ private CustomUiHost _ui = new CustomUiHost();
+ public override CustomUiHost UI => _ui;
+
+ public override CultureInfo CurrentCulture { get; } = CultureInfo.CurrentCulture;
+ public override CultureInfo CurrentUICulture { get; } = CultureInfo.CurrentUICulture;
+
+ public CustomHost(int width, int height = 100)
+ {
+ _ui.RawUI.WindowSize = new() { Width = width, Height = height };
+ }
+
+ public override void EnterNestedPrompt()
+ {
+ throw new NotImplementedException("EnterNestedPrompt is not implemented. The script is asking for input, which is a problem since there's no console. Make sure the script can execute without prompting the user for input.");
+ }
+
+ public override void ExitNestedPrompt()
+ {
+ throw new NotImplementedException("ExitNestedPrompt is not implemented. The script is asking for input, which is a problem since there's no console. Make sure the script can execute without prompting the user for input.");
+ }
+
+ public override void NotifyBeginApplication()
+ {
+ }
+
+ public override void NotifyEndApplication()
+ {
+ }
+
+ public override void SetShouldExit(int exitCode)
+ {
+ }
+}
+
+// https://github.com/leechristensen/OffensivePowerShellTasking/blob/d1b498d874948f41e6dc053204c511fa6fc11c9c/OffensivePowerShellTasking/CustomPSHostUserInterface.cs#L8
+public class CustomUiHost : PSHostUserInterface
+{
+ // The only stuff that really matters to expose a host for any cmdlets
+ private readonly CustomRawUiHost _rawUI = new CustomRawUiHost();
+ public override CustomRawUiHost RawUI => _rawUI;
+
+ // Replace StringBuilder with whatever your preferred output method is (e.g. a socket or a named pipe)
+ public StringBuilder output { get; set; }
+
+ public CustomUiHost()
+ {
+ output = new StringBuilder();
+ }
+
+ public CustomUiHost(ref StringBuilder sb)
+ {
+ output = sb;
+ }
+
+ public override void Write(ConsoleColor foregroundColor, ConsoleColor backgroundColor, string value)
+ {
+ //output.Append("!").Append(value);
+ }
+
+ public override void WriteLine()
+ {
+ //output.Append("!").Append("\n");
+ }
+
+ public override void WriteLine(ConsoleColor foregroundColor, ConsoleColor backgroundColor, string value)
+ {
+ //output.Append("!").Append(value + "\n");
+ }
+
+ public override void Write(string value)
+ {
+ //output.Append("!").Append(value);
+ }
+
+ public override void WriteDebugLine(string message)
+ {
+ //output.Append("!").AppendLine("DEBUG: " + message);
+ }
+
+ public override void WriteErrorLine(string value)
+ {
+ //output.Append("!").AppendLine("ERROR: " + value);
+ }
+
+ public override void WriteLine(string value)
+ {
+ //output.Append("!").AppendLine(value);
+ }
+
+ public override void WriteVerboseLine(string message)
+ {
+ //output.Append("!").AppendLine("VERBOSE: " + message);
+ }
+
+ public override void WriteWarningLine(string message)
+ {
+ //output.Append("!").AppendLine("WARNING: " + message);
+ }
+
+ public override void WriteProgress(long sourceId, ProgressRecord record)
+ {
+ }
+
+ public string Output => output.ToString();
+
+ public override Dictionary Prompt(string caption, string message, System.Collections.ObjectModel.Collection descriptions)
+ {
+ throw new NotImplementedException("Prompt is not implemented. The script is asking for input, which is a problem since there's no console. Make sure the script can execute without prompting the user for input.");
+ }
+
+ public override int PromptForChoice(string caption, string message, System.Collections.ObjectModel.Collection choices, int defaultChoice)
+ {
+ throw new NotImplementedException("PromptForChoice is not implemented. The script is asking for input, which is a problem since there's no console. Make sure the script can execute without prompting the user for input.");
+ }
+
+ public override PSCredential PromptForCredential(string caption, string message, string userName, string targetName, PSCredentialTypes allowedCredentialTypes, PSCredentialUIOptions options)
+ {
+ throw new NotImplementedException("PromptForCredential1 is not implemented. The script is asking for input, which is a problem since there's no console. Make sure the script can execute without prompting the user for input.");
+ }
+
+ public override PSCredential PromptForCredential(string caption, string message, string userName, string targetName)
+ {
+ throw new NotImplementedException("PromptForCredential2 is not implemented. The script is asking for input, which is a problem since there's no console. Make sure the script can execute without prompting the user for input.");
+ }
+
+ public override string ReadLine()
+ {
+ throw new NotImplementedException("ReadLine is not implemented. The script is asking for input, which is a problem since there's no console. Make sure the script can execute without prompting the user for input.");
+ }
+
+ public override SecureString ReadLineAsSecureString()
+ {
+ throw new NotImplementedException("ReadLineAsSecureString is not implemented. The script is asking for input, which is a problem since there's no console. Make sure the script can execute without prompting the user for input.");
+ }
+}
+
+// https://github.com/leechristensen/OffensivePowerShellTasking/blob/master/OffensivePowerShellTasking/CustomPSRHostRawUserInterface.cs#L8
+public class CustomRawUiHost : PSHostRawUserInterface
+{
+ public override ConsoleColor BackgroundColor { get; set; } = ConsoleColor.Black;
+
+ public override Size BufferSize { get; set; } = new Size { Width = 100, Height = 1000 };
+
+ public override Coordinates CursorPosition { get; set; } = new Coordinates { X = 0, Y = 0 };
+
+ public override int CursorSize { get; set; } = 1;
+
+ public override void FlushInputBuffer()
+ {
+ throw new NotImplementedException("FlushInputBuffer is not implemented.");
+ }
+
+ public override ConsoleColor ForegroundColor { get; set; } = ConsoleColor.White;
+
+ public override BufferCell[,] GetBufferContents(Rectangle rectangle)
+ {
+ throw new NotImplementedException("GetBufferContents is not implemented.");
+ }
+
+ public override bool KeyAvailable
+ {
+ get { throw new NotImplementedException("KeyAvailable is not implemented."); }
+ }
+
+ public override Size MaxPhysicalWindowSize { get; } = new Size
+ {
+ Width = int.MaxValue,
+ Height = int.MaxValue
+ };
+
+ public override Size MaxWindowSize { get; } = new Size { Width = 100, Height = 100 };
+
+ public override KeyInfo ReadKey(ReadKeyOptions options)
+ {
+ throw new NotImplementedException("ReadKey is not implemented. The script is asking for input, which is a problem since there's no console. Make sure the script can execute without prompting the user for input.");
+ }
+
+ public override void ScrollBufferContents(Rectangle source, Coordinates destination, Rectangle clip, BufferCell fill)
+ {
+ throw new NotImplementedException("ScrollBufferContents is not implemented");
+ }
+
+ public override void SetBufferContents(Rectangle rectangle, BufferCell fill)
+ {
+ throw new NotImplementedException("SetBufferContents is not implemented.");
+ }
+
+ public override void SetBufferContents(Coordinates origin, BufferCell[,] contents)
+ {
+ throw new NotImplementedException("SetBufferContents is not implemented");
+ }
+
+ public override Coordinates WindowPosition { get; set; } = new Coordinates { X = 0, Y = 0 };
+
+ public override Size WindowSize { get; set; } = new Size { Width = 120, Height = 100 };
+
+ public override string WindowTitle { get; set; } = "";
+}
\ No newline at end of file
diff --git a/src/PowershellHarness/PowershellHarness.csproj b/src/PowershellHarness/PowershellHarness.csproj
new file mode 100644
index 0000000..80cdb76
--- /dev/null
+++ b/src/PowershellHarness/PowershellHarness.csproj
@@ -0,0 +1,19 @@
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/PowershellHarness/Program.cs b/src/PowershellHarness/Program.cs
new file mode 100644
index 0000000..077a9b7
--- /dev/null
+++ b/src/PowershellHarness/Program.cs
@@ -0,0 +1,145 @@
+using System.Management.Automation;
+using System.Management.Automation.Runspaces;
+using System.Text;
+using PowershellModule.Calendar;
+
+namespace PowershellHarness;
+
+// https://github.com/FuseCP/FuseCP/blob/278a19dc06949600f25a1b4ed74d0419a8fa3fc2/FuseCP/Sources/FuseCP.Providers.HostedSolution.SfB2015/SfBBase.cs#L224
+// some useful code in here tbh
+class Program
+{
+ static void Main(string[] args)
+ {
+ // I've gotta work out how to get the rider terminal to act closer to powershell because .PadLeft in powershell
+ // will correctly output lines when output with WriteOutput, but the Jetbrains debug worker (or whatever
+ // it is that rider spawns), has none of that and operates in its own world.
+ // Which is fine if you never want new lines.
+ // Ideally you'd just launch pwsh.exe and attach to that, but _that_ has its own issues as well.
+ // The new line issue might be related to how I've set up the host seeing as I just did a bunch of copy paste shit
+ // from something I found on github to get it across the line so I could debug it.
+ // I'll eventually revisit those hosts and rewrite them properly once I start adding more commands, and if I care.
+ // Threads & variables has all the visuals I need after all.
+#if DEBUG
+ if (!System.Diagnostics.Debugger.IsAttached)
+ {
+ Console.BackgroundColor = ConsoleColor.Yellow;
+ Console.WriteLine(CenterText("Running without a debugger attached may result in lines not being output correctly"));
+ Console.ResetColor();
+ }
+#endif
+ Console.WriteLine(CenterText(" PowerShell Debug Harness "));
+ Console.WriteLine($"Reported console window size: {Console.WindowWidth}x{Console.WindowHeight}");
+ Console.WriteLine($"Reported console buffer size: {Console.BufferWidth}x{Console.BufferHeight}");
+ Console.WriteLine(CenterText(" WIDTH ", '-'));
+
+ var host = new CustomHost(Console.WindowWidth);
+ var runspace = InitialisePowershellHost(host);
+
+ // InvokeCommand(runspace, GetCalendarCommand.FullName);
+
+ foreach (var day in Enum.GetValues())
+ {
+ InvokeCommand(runspace, GetCalendarCommand.FullName, [
+ CreateCommand(nameof(GetCalendarCommand.MarkedDaySymbol), "faker"),
+ CreateCommand(nameof(GetCalendarCommand.StartOfWeek), day.ToString()),
+ CreateCommand(nameof(GetCalendarCommand.Date), "22/6/26"),
+ // CreateCommand(nameof(GetCalendarCommand.AlignRight))
+ ]);
+ }
+
+ // InvokeCommand(runspace, GetCalendarCommand.FullName, [
+ // new CommandParameter(nameof(GetCalendarCommand.MarkedDaySymbol), "🤫"),
+ // new CommandParameter(nameof(GetCalendarCommand.StartOfWeek), DayOfWeek.Saturday)
+ // ]);
+ // InvokeCommand(runspace, GetCalendarCommand.FullName, [new CommandParameter(nameof(GetCalendarCommand.StartOfWeek), "Sunday")]);
+ }
+
+ private static CommandParameter CreateCommand(string name, string? argument = null)
+ {
+ if (argument is not null)
+ {
+ return new CommandParameter(name, argument);
+ }
+
+ return new CommandParameter(name);
+ }
+
+ static string CenterText(string text, char paddingChar = ' ')
+ {
+ return text.PadLeft((Console.WindowWidth + text.Length) / 2, paddingChar).PadRight(Console.WindowWidth, paddingChar);
+ }
+
+ ///
+ /// Initialises a PowerShell session and returns an open Runspace
+ ///
+ ///
+ ///
+ static Runspace InitialisePowershellHost(CustomHost host)
+ {
+ // Create the initial session state for the host. Yes, that 2 on the end does indicate that the underlying
+ // implementation for this is C++
+ // Welcome to the lands of fuck all documentation and figuring shit out from an increasingly shit internet
+ // where finding non-slop answers gets harder by the day as people close sources of information to prevent
+ // scraping. Fuck every single person involved modern AI.
+ // It wasn't much better finding documentation about the System.Management.* namespace before that, but it's
+ // definitely a lot worse because of it
+ var initialSessionState = InitialSessionState.CreateDefault2();
+
+
+ // No idea what the helpFileName should be. As is common with _a lot_ of the System.Management.* namespace,
+ // fuck all is actually documented with comments, or documented at all!
+ // From https://learn.microsoft.com/en-us/powershell/scripting/developer/hosting/creating-a-constrained-runspace?view=powershell-7.6
+ // (which yes, this section _is_ under a legacy category!), it's perfectly fine to leave it as null.
+ // We also don't really care as we're just doing this so we can test commands without having to deal with Rider
+ // and it's quirks around runnning a powershell terminal and attaching to it. Yeah it technically works, but
+ // it's way too common to end up with Rider refusing to build correctly or ensure the right dll is used for the module
+ var getCalendarCommand = new SessionStateCmdletEntry(GetCalendarCommand.FullName, typeof(GetCalendarCommand), null);
+ initialSessionState.Commands.Add(getCalendarCommand);
+
+ // Create a runspace from the state, open and return it
+ var runspace = RunspaceFactory.CreateRunspace(host, initialSessionState);
+
+ // A runspace is technically disposable, but we're not reimplementing a full host and we won't be doing anything
+ // that would require a fresh runspace multiple times over (yet), so we can just treat the lifetime of the
+ // disposable as application lifetime
+ runspace.Open();
+
+ return runspace;
+ }
+
+ static void InvokeCommand(Runspace runspace, string command, IEnumerable? parameters = null)
+ {
+ // Not too sure on the difference of runspace vs PowerShell here. Doesn't seem to make a difference either way
+ // and this is just a debug harness so it doesn't really matter for now
+ using var pipeline = runspace.CreatePipeline();
+ // using var powershell = PowerShell.Create(runspace);
+
+ var cmd = new Command(command);
+ if (parameters is not null)
+ {
+ foreach (var commandParameter in parameters)
+ {
+ cmd.Parameters.Add(commandParameter);
+ }
+ }
+
+ pipeline.Commands.Add(cmd);
+ // powershell.Commands.AddCommand(cmd);
+
+ try
+ {
+ var results = pipeline.Invoke();
+ // var results = powershell.Invoke();
+ foreach (var result in results)
+ {
+ Console.Write(result);
+ }
+ }
+ catch (Exception ex)
+ {
+ Console.Error.WriteLine($"Unable to execute command {command}");
+ Console.Error.WriteLine(ex.GetBaseException().Message);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/PowershellModule/Calendar/GetCalendarCommand.cs b/src/PowershellModule/Calendar/GetCalendarCommand.cs
new file mode 100644
index 0000000..f70d6e0
--- /dev/null
+++ b/src/PowershellModule/Calendar/GetCalendarCommand.cs
@@ -0,0 +1,174 @@
+using System;
+using System.Globalization;
+using System.Management.Automation;
+using ModuleCore.Calendar;
+
+namespace PowershellModule.Calendar
+{
+ [Cmdlet(VerbsCommon.Get, Noun)]
+ public class GetCalendarCommand : PSCmdlet
+ {
+#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable.
+ [Parameter(
+ Mandatory = false,
+ Position = 0)]
+ public string Date { get; set; }
+
+ [Parameter(
+ Mandatory = false,
+ Position = 1)]
+ public string MarkedDaySymbol { get; set; }
+
+ [Parameter(
+ Mandatory = false,
+ Position = 2)]
+ [ValidateSet("Sunday",
+ "Monday",
+ "Tuesday",
+ "Wednesday",
+ "Thursday",
+ "Friday",
+ "Saturday")]
+ // pass a type in to get dynamic types
+ // NOTE: the generator gets called for _each_ character input, so ensure the values are idempotent for every
+ // keydown (and fast), or cached
+ // [ValidateSet(typeof(StartDayOfWeekGenerator))]
+ public string StartOfWeek { get; set; }
+
+ [Parameter(
+ Mandatory = false,
+ Position = 3)]
+ public SwitchParameter AlignRight { get; set; }
+
+#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable.
+
+ // Guaranteed to be initialised in BeginProcessing. If it's not, something is cooked in some other way
+ private CalendarGenerator _calendar = null!;
+ private const string Noun = "Calendar";
+ public const string FullName = $"{VerbsCommon.Get}-{Noun}";
+
+ private DateTime _dateToRender = DateTime.Now;
+ private int _leftPadding;
+
+ protected override void BeginProcessing()
+ {
+ // This is the first point in script execution where we'll have parameters populated
+ _calendar = new(
+ MarkedDaySymbol,
+ DayOfWeekFromString(StartOfWeek)
+ );
+
+ if (!string.IsNullOrWhiteSpace(Date))
+ {
+ _dateToRender = DateTime.ParseExact(Date, CultureInfo.CurrentCulture.DateTimeFormat.GetAllDateTimePatterns(), null);
+ }
+
+ if (AlignRight)
+ {
+ _leftPadding = Host.UI.RawUI.WindowSize.Width;
+ }
+ }
+
+ ///
+ /// Returns a from a string.
+ ///
+ /// Defaults to the calendars default start of week on null or invalid values.
+ ///
+ ///
+ ///
+ ///
+ private static DayOfWeek DayOfWeekFromString(string? dayOfWeek)
+ {
+ if (dayOfWeek is null)
+ {
+ return CalendarGenerator.DefaultStartOfWeek;
+ }
+
+ return dayOfWeek.ToLowerInvariant() switch
+ {
+ "sunday" => DayOfWeek.Sunday,
+ "monday" => DayOfWeek.Monday,
+ "tuesday" => DayOfWeek.Tuesday,
+ "wednesday" => DayOfWeek.Wednesday,
+ "thursday" => DayOfWeek.Thursday,
+ "friday" => DayOfWeek.Friday,
+ "saturday" => DayOfWeek.Saturday,
+ _ => CalendarGenerator.DefaultStartOfWeek
+ };
+ }
+
+ protected override void ProcessRecord()
+ {
+ var calendar = _calendar.Render(_dateToRender);
+
+ // From _very_ basic testing, string split allocates the same as making a span and iterating over it.
+ // I'm assuming this is because a lot of the span code is what string.Split() does internally and the rest
+ // is trivially optimised out.
+ // Not too fussed currently but it's most likely because Pad methods also allocate a string, so they end up
+ // equivalent at the end
+ // OutputCalendarStringSplit(calendar);
+ OutputCalendar(calendar);
+
+ base.ProcessRecord();
+ }
+
+ private void OutputCalendarStringSplit(string calendar)
+ {
+ var splitCalendarLines = calendar.Split(Environment.NewLine);
+ foreach (var splitLines in splitCalendarLines)
+ {
+ // Have to output a newline character here
+ WriteObject(splitLines.PadLeft(_leftPadding));
+ }
+ }
+
+ private void OutputCalendar(string calendar)
+ {
+ // Work on a span - we avoid using string.Split() as this allocates on certain runtimes
+ var calendarSpan = calendar.AsSpan();
+ // Get the end of line for the calendar. We use Environment.NewLine as the calendar is built up using
+ // StringBuilder which uses Environment.NewLine for new lines so this should be consistent across operating systems
+ var firstNewlineIndex = calendarSpan.IndexOf(Environment.NewLine);
+ // Calculate how many lines the calendar is from how many new lines we have.
+ // Sure we could count the number of \r\n are present, but the last line might not end with
+ // a new line.
+ // Conveniently enough, int division works out in our favor here
+ var lines = calendarSpan.Length / firstNewlineIndex;
+
+ // We need to account for the size of the newline so we have an accurate length when outputting each line
+ // of the calendar below
+ var newLineLength = firstNewlineIndex + Environment.NewLine.Length;
+
+ for (int i = 0; i < lines; i++)
+ {
+ // The start of each calendar line offset starts at a position including the length of the newline,
+ // so in effect the start of the line is the length of the previous line output, plus however many
+ // characters the newline was that weren't output as we're taking control there
+ var startOfLine = i * newLineLength;
+ // End of the line is exclusive of any newline characters
+ var endOfLine = startOfLine + firstNewlineIndex;
+ var calendarLine = calendarSpan[startOfLine .. endOfLine];
+
+ WriteObject(calendarLine.ToString().PadLeft(_leftPadding));
+ }
+ }
+ }
+
+ // Was here to test dynamic day names but most people using the console will be using english command names
+ // so there's at least some assumption that they understand days of the week in english.
+ // It sucks but it remains at least consistent with the argument being in english as well.
+ // public class StartDayOfWeekGenerator : IValidateSetValuesGenerator
+ // {
+ // private string[] _cached { get; set; } = new string[7];
+ //
+ // public string[] GetValidValues()
+ // {
+ // if (_cached is null)
+ // {
+ // // _cached = Enumerable.Range(0, r.Next(3, 63)).Select(x => x.ToString()).ToArray();
+ // }
+ //
+ // return _cached;
+ // }
+ // }
+}
\ No newline at end of file
diff --git a/src/PowershellModule/Directory.Build.props b/src/PowershellModule/Directory.Build.props
new file mode 100644
index 0000000..afa6e9c
--- /dev/null
+++ b/src/PowershellModule/Directory.Build.props
@@ -0,0 +1,6 @@
+
+
+ 0.0.0
+ dev
+
+
\ No newline at end of file
diff --git a/src/PowershellModule/PowershellModule.csproj b/src/PowershellModule/PowershellModule.csproj
new file mode 100644
index 0000000..c7f5801
--- /dev/null
+++ b/src/PowershellModule/PowershellModule.csproj
@@ -0,0 +1,20 @@
+
+
+
+ net10.0
+ PowershellModule
+ latestmajor
+ enable
+
+
+
+
+ All
+
+
+
+
+
+
+
+
diff --git a/src/PowershellModule/manifest.json b/src/PowershellModule/manifest.json
new file mode 100644
index 0000000..3039b76
--- /dev/null
+++ b/src/PowershellModule/manifest.json
@@ -0,0 +1,5 @@
+{
+ "_TODO":[
+ "add manifest details here maybe idk"
+ ]
+}
\ No newline at end of file
diff --git a/src/PowershellModule/meta/PowershellModule.psd1 b/src/PowershellModule/meta/PowershellModule.psd1
new file mode 100644
index 0000000..e69de29
diff --git a/src/PowershellModule/meta/PowershellModule.psm1 b/src/PowershellModule/meta/PowershellModule.psm1
new file mode 100644
index 0000000..e69de29
diff --git a/tests/ModuleTests/Calendar/BasicRenderTest.cs b/tests/ModuleTests/Calendar/BasicRenderTest.cs
new file mode 100644
index 0000000..3bc791f
--- /dev/null
+++ b/tests/ModuleTests/Calendar/BasicRenderTest.cs
@@ -0,0 +1,48 @@
+using ModuleCore.Calendar;
+using ModuleTests.Calendar.TestData;
+using System.Globalization;
+
+namespace ModuleTests.Calendar;
+
+public class BasicRenderTest
+{
+ private static readonly VerifySettings Settings;
+
+ static BasicRenderTest()
+ {
+ Settings = new VerifySettings();
+ var testBaseDirectory = Path.Join(TestConstants.SnapshotFolderName, nameof(BasicRenderTest));
+
+ Settings.UseDirectory(testBaseDirectory);
+ Settings.DisableDiff();
+ }
+
+ [Theory]
+ [ClassData(typeof(CalendarTestDates))]
+ public Task BasicCalendarRender(DateTime month)
+ {
+ Settings.UseFileName($"{month:yyyy-MM-dd}");
+
+ var calendar = new CalendarGenerator();
+ // Render the calendar with the start of the month as the today date so no elapsed marks are shown
+ var renderedMonth = calendar.Render(month, new DateTime(month.Year, month.Month, 1));
+
+ return Verify(renderedMonth, Settings);
+ }
+
+ [Theory]
+ [ClassData(typeof(CultureCodeTestData))]
+ public Task CultureInfoCalendarRender(string cultureCode)
+ {
+ Settings.UseFileName($"{cultureCode}");
+
+ Thread.CurrentThread.CurrentCulture = new CultureInfo(cultureCode);
+
+ var month = new DateTime(2026, 8, 1);
+ var calendar = new CalendarGenerator();
+ // Render the calendar with the start of the month as the today date so no elapsed marks are shown
+ var renderedMonth = calendar.Render(month, new DateTime(month.Year, month.Month, 1));
+
+ return Verify(renderedMonth, Settings);
+ }
+}
\ No newline at end of file
diff --git a/tests/ModuleTests/Calendar/MarkedDayRenderTests.cs b/tests/ModuleTests/Calendar/MarkedDayRenderTests.cs
new file mode 100644
index 0000000..24af2ce
--- /dev/null
+++ b/tests/ModuleTests/Calendar/MarkedDayRenderTests.cs
@@ -0,0 +1,86 @@
+using System.Text;
+using ModuleCore.Calendar;
+
+namespace ModuleTests.Calendar;
+
+public class MarkedDayRenderTests
+{
+ private static readonly VerifySettings Settings;
+
+ static MarkedDayRenderTests()
+ {
+ Settings = new VerifySettings();
+ var testBaseDirectory = Path.Join(TestConstants.SnapshotFolderName, nameof(MarkedDayRenderTests));
+
+ Settings.UseDirectory(testBaseDirectory);
+ Settings.DisableDiff();
+ }
+
+ [Fact]
+ public Task DefaultMarkedDayRender()
+ {
+ Settings.UseFileName(nameof(DefaultMarkedDayRender));
+
+ var date = new DateTime(2026, 8, 1);
+ var calendar = new CalendarGenerator();
+ var renderedMonth = calendar.Render(date, date.AddDays(15));
+
+ return Verify(renderedMonth, Settings);
+ }
+
+ [Fact]
+ public Task SimpleMarkedDayRender()
+ {
+ Settings.UseFileName(nameof(SimpleMarkedDayRender));
+
+ var date = new DateTime(2026, 8, 1);
+ var calendar = new CalendarGenerator("|");
+ var renderedMonth = calendar.Render(date, date.AddDays(15));
+
+ return Verify(renderedMonth, Settings);
+ }
+
+ [Fact]
+ public Task LongMarkedDayRender()
+ {
+ Settings.UseFileName(nameof(LongMarkedDayRender));
+
+ var date = new DateTime(2026, 8, 1);
+ var calendar = new CalendarGenerator("passed");
+ var renderedMonth = calendar.Render(date, date.AddDays(15));
+
+ return Verify(renderedMonth, Settings);
+ }
+
+ [Fact]
+ public Task EmojiMarkedDayRender()
+ {
+ Settings.UseFileName(nameof(EmojiMarkedDayRender));
+
+ var date = new DateTime(2026, 8, 1);
+ var calendar = new CalendarGenerator("🤫");
+ var sb = new StringBuilder();
+ sb.AppendLine("Emojis will display incorrectly in text but output correctly in terminals with UTF-8 encoding.")
+ .AppendLine(calendar.Render(date, date.AddDays(15)));
+
+ return Verify(sb, Settings);
+ }
+
+ [Fact]
+ public Task DoubleWideMarkedDayRender()
+ {
+ Settings.UseFileName(nameof(DoubleWideMarkedDayRender));
+
+ var date = new DateTime(2026, 8, 1);
+ // Using an asian-character to have a test covering the unfortunate reality of character rendering of
+ // characters that are visually 1 character in width, but render as double width or some other cursed form.
+ // Written language was a mistake but trying to render it on computers alongside a different one
+ // is arguably worse.
+ var calendar = new CalendarGenerator("火");
+ var sb = new StringBuilder();
+ sb.AppendLine("Double-wide characters will display incorrectly but still tested until rendering can be improved")
+ .AppendLine(calendar.Render(date, date.AddDays(15)));
+
+ return Verify(sb, Settings);
+ }
+}
\ No newline at end of file
diff --git a/tests/ModuleTests/Calendar/StartDayOfWeekTests.cs b/tests/ModuleTests/Calendar/StartDayOfWeekTests.cs
new file mode 100644
index 0000000..c6680c7
--- /dev/null
+++ b/tests/ModuleTests/Calendar/StartDayOfWeekTests.cs
@@ -0,0 +1,35 @@
+using System.Text;
+using ModuleCore.Calendar;
+
+namespace ModuleTests.Calendar;
+
+public class StartDayOfWeekTests
+{
+ private static readonly VerifySettings Settings;
+
+ static StartDayOfWeekTests()
+ {
+ Settings = new VerifySettings();
+ var testBaseDirectory = Path.Join(TestConstants.SnapshotFolderName, nameof(StartDayOfWeekTests));
+
+ Settings.UseDirectory(testBaseDirectory);
+ Settings.DisableDiff();
+ }
+
+ [Fact]
+ public Task StartOfDayRender()
+ {
+ Settings.UseFileName(nameof(StartOfDayRender));
+
+ var date = new DateTime(2026, 8, 1);
+ var sb = new StringBuilder();
+ foreach (var dayOfWeek in Enum.GetValues())
+ {
+ var calendar = new CalendarGenerator(startOfWeek: dayOfWeek);
+ sb.AppendLine($"Start Day: {dayOfWeek}");
+ sb.AppendLine(calendar.Render(date, date));
+ }
+
+ return Verify(sb, Settings);
+ }
+}
\ No newline at end of file
diff --git a/tests/ModuleTests/Calendar/TestData/CalendarTestDates.cs b/tests/ModuleTests/Calendar/TestData/CalendarTestDates.cs
new file mode 100644
index 0000000..189b4a4
--- /dev/null
+++ b/tests/ModuleTests/Calendar/TestData/CalendarTestDates.cs
@@ -0,0 +1,14 @@
+namespace ModuleTests.Calendar.TestData;
+
+public class CalendarTestDates : TestDataEnumerator
+{
+ public CalendarTestDates()
+ {
+ Data =
+ [
+ new DateTime(2026, 06, 01),
+ new DateTime(2026, 07, 01),
+ new DateTime(2026, 08, 01),
+ ];
+ }
+}
\ No newline at end of file
diff --git a/tests/ModuleTests/Calendar/TestData/CultureCodeTestData.cs b/tests/ModuleTests/Calendar/TestData/CultureCodeTestData.cs
new file mode 100644
index 0000000..9bdf1f0
--- /dev/null
+++ b/tests/ModuleTests/Calendar/TestData/CultureCodeTestData.cs
@@ -0,0 +1,19 @@
+namespace ModuleTests.Calendar.TestData;
+
+public class CultureCodeTestData : TestDataEnumerator
+{
+ public CultureCodeTestData()
+ {
+ Data =
+ [
+ "da-DK",
+ "en-AU",
+ "cs-CZ",
+ "es-PR",
+ "fr-LU",
+ "nl-NL",
+ // Will render unaligned due to the character encoding, here to catch if I fix this later
+ "te-IN"
+ ];
+ }
+}
\ No newline at end of file
diff --git a/tests/ModuleTests/Calendar/snapshots/BasicRenderTest/2026-06-01.verified.txt b/tests/ModuleTests/Calendar/snapshots/BasicRenderTest/2026-06-01.verified.txt
new file mode 100644
index 0000000..de75dd0
--- /dev/null
+++ b/tests/ModuleTests/Calendar/snapshots/BasicRenderTest/2026-06-01.verified.txt
@@ -0,0 +1,11 @@
+┌─────────────────────────────────────────┐
+│ June 2026 │
+├─────┬─────┬─────┬─────┬─────┬─────┬─────┤
+│ Mon │ Tue │ Wed │ Thu │ Fri │ Sat │ Sun │
+├─────┼─────┼─────┼─────┼─────┼─────┼─────┤
+│ 1 │ 2 │ 3 │ 4 │ 5 │ 6 │ 7 │
+│ 8 │ 9 │ 10 │ 11 │ 12 │ 13 │ 14 │
+│ 15 │ 16 │ 17 │ 18 │ 19 │ 20 │ 21 │
+│ 22 │ 23 │ 24 │ 25 │ 26 │ 27 │ 28 │
+│ 29 │ 30 │ │ │ │ │ │
+└─────┴─────┴─────┴─────┴─────┴─────┴─────┘
\ No newline at end of file
diff --git a/tests/ModuleTests/Calendar/snapshots/BasicRenderTest/2026-07-01.verified.txt b/tests/ModuleTests/Calendar/snapshots/BasicRenderTest/2026-07-01.verified.txt
new file mode 100644
index 0000000..a74744c
--- /dev/null
+++ b/tests/ModuleTests/Calendar/snapshots/BasicRenderTest/2026-07-01.verified.txt
@@ -0,0 +1,11 @@
+┌─────────────────────────────────────────┐
+│ July 2026 │
+├─────┬─────┬─────┬─────┬─────┬─────┬─────┤
+│ Mon │ Tue │ Wed │ Thu │ Fri │ Sat │ Sun │
+├─────┼─────┼─────┼─────┼─────┼─────┼─────┤
+│ │ │ 1 │ 2 │ 3 │ 4 │ 5 │
+│ 6 │ 7 │ 8 │ 9 │ 10 │ 11 │ 12 │
+│ 13 │ 14 │ 15 │ 16 │ 17 │ 18 │ 19 │
+│ 20 │ 21 │ 22 │ 23 │ 24 │ 25 │ 26 │
+│ 27 │ 28 │ 29 │ 30 │ 31 │ │ │
+└─────┴─────┴─────┴─────┴─────┴─────┴─────┘
\ No newline at end of file
diff --git a/tests/ModuleTests/Calendar/snapshots/BasicRenderTest/2026-08-01.verified.txt b/tests/ModuleTests/Calendar/snapshots/BasicRenderTest/2026-08-01.verified.txt
new file mode 100644
index 0000000..9598b49
--- /dev/null
+++ b/tests/ModuleTests/Calendar/snapshots/BasicRenderTest/2026-08-01.verified.txt
@@ -0,0 +1,12 @@
+┌─────────────────────────────────────────┐
+│ August 2026 │
+├─────┬─────┬─────┬─────┬─────┬─────┬─────┤
+│ Mon │ Tue │ Wed │ Thu │ Fri │ Sat │ Sun │
+├─────┼─────┼─────┼─────┼─────┼─────┼─────┤
+│ │ │ │ │ │ 1 │ 2 │
+│ 3 │ 4 │ 5 │ 6 │ 7 │ 8 │ 9 │
+│ 10 │ 11 │ 12 │ 13 │ 14 │ 15 │ 16 │
+│ 17 │ 18 │ 19 │ 20 │ 21 │ 22 │ 23 │
+│ 24 │ 25 │ 26 │ 27 │ 28 │ 29 │ 30 │
+│ 31 │ │ │ │ │ │ │
+└─────┴─────┴─────┴─────┴─────┴─────┴─────┘
\ No newline at end of file
diff --git a/tests/ModuleTests/Calendar/snapshots/BasicRenderTest/cs-CZ.verified.txt b/tests/ModuleTests/Calendar/snapshots/BasicRenderTest/cs-CZ.verified.txt
new file mode 100644
index 0000000..797c58a
--- /dev/null
+++ b/tests/ModuleTests/Calendar/snapshots/BasicRenderTest/cs-CZ.verified.txt
@@ -0,0 +1,12 @@
+┌──────────────────────────────────┐
+│ srpen 2026 │
+├────┬────┬────┬────┬────┬────┬────┤
+│ po │ út │ st │ čt │ pá │ so │ ne │
+├────┼────┼────┼────┼────┼────┼────┤
+│ │ │ │ │ │ 1 │ 2 │
+│ 3 │ 4 │ 5 │ 6 │ 7 │ 8 │ 9 │
+│ 10 │ 11 │ 12 │ 13 │ 14 │ 15 │ 16 │
+│ 17 │ 18 │ 19 │ 20 │ 21 │ 22 │ 23 │
+│ 24 │ 25 │ 26 │ 27 │ 28 │ 29 │ 30 │
+│ 31 │ │ │ │ │ │ │
+└────┴────┴────┴────┴────┴────┴────┘
\ No newline at end of file
diff --git a/tests/ModuleTests/Calendar/snapshots/BasicRenderTest/da-DK.verified.txt b/tests/ModuleTests/Calendar/snapshots/BasicRenderTest/da-DK.verified.txt
new file mode 100644
index 0000000..58618a1
--- /dev/null
+++ b/tests/ModuleTests/Calendar/snapshots/BasicRenderTest/da-DK.verified.txt
@@ -0,0 +1,12 @@
+┌─────────────────────────────────────────┐
+│ august 2026 │
+├─────┬─────┬─────┬─────┬─────┬─────┬─────┤
+│ man │ tir │ ons │ tor │ fre │ lør │ søn │
+├─────┼─────┼─────┼─────┼─────┼─────┼─────┤
+│ │ │ │ │ │ 1 │ 2 │
+│ 3 │ 4 │ 5 │ 6 │ 7 │ 8 │ 9 │
+│ 10 │ 11 │ 12 │ 13 │ 14 │ 15 │ 16 │
+│ 17 │ 18 │ 19 │ 20 │ 21 │ 22 │ 23 │
+│ 24 │ 25 │ 26 │ 27 │ 28 │ 29 │ 30 │
+│ 31 │ │ │ │ │ │ │
+└─────┴─────┴─────┴─────┴─────┴─────┴─────┘
\ No newline at end of file
diff --git a/tests/ModuleTests/Calendar/snapshots/BasicRenderTest/en-AU.verified.txt b/tests/ModuleTests/Calendar/snapshots/BasicRenderTest/en-AU.verified.txt
new file mode 100644
index 0000000..9598b49
--- /dev/null
+++ b/tests/ModuleTests/Calendar/snapshots/BasicRenderTest/en-AU.verified.txt
@@ -0,0 +1,12 @@
+┌─────────────────────────────────────────┐
+│ August 2026 │
+├─────┬─────┬─────┬─────┬─────┬─────┬─────┤
+│ Mon │ Tue │ Wed │ Thu │ Fri │ Sat │ Sun │
+├─────┼─────┼─────┼─────┼─────┼─────┼─────┤
+│ │ │ │ │ │ 1 │ 2 │
+│ 3 │ 4 │ 5 │ 6 │ 7 │ 8 │ 9 │
+│ 10 │ 11 │ 12 │ 13 │ 14 │ 15 │ 16 │
+│ 17 │ 18 │ 19 │ 20 │ 21 │ 22 │ 23 │
+│ 24 │ 25 │ 26 │ 27 │ 28 │ 29 │ 30 │
+│ 31 │ │ │ │ │ │ │
+└─────┴─────┴─────┴─────┴─────┴─────┴─────┘
\ No newline at end of file
diff --git a/tests/ModuleTests/Calendar/snapshots/BasicRenderTest/es-PR.verified.txt b/tests/ModuleTests/Calendar/snapshots/BasicRenderTest/es-PR.verified.txt
new file mode 100644
index 0000000..9395157
--- /dev/null
+++ b/tests/ModuleTests/Calendar/snapshots/BasicRenderTest/es-PR.verified.txt
@@ -0,0 +1,12 @@
+┌────────────────────────────────────────────────┐
+│ agosto 2026 │
+├──────┬──────┬──────┬──────┬──────┬──────┬──────┤
+│ lun. │ mar. │ mié. │ jue. │ vie. │ sáb. │ dom. │
+├──────┼──────┼──────┼──────┼──────┼──────┼──────┤
+│ │ │ │ │ │ 1 │ 2 │
+│ 3 │ 4 │ 5 │ 6 │ 7 │ 8 │ 9 │
+│ 10 │ 11 │ 12 │ 13 │ 14 │ 15 │ 16 │
+│ 17 │ 18 │ 19 │ 20 │ 21 │ 22 │ 23 │
+│ 24 │ 25 │ 26 │ 27 │ 28 │ 29 │ 30 │
+│ 31 │ │ │ │ │ │ │
+└──────┴──────┴──────┴──────┴──────┴──────┴──────┘
\ No newline at end of file
diff --git a/tests/ModuleTests/Calendar/snapshots/BasicRenderTest/fr-LU.verified.txt b/tests/ModuleTests/Calendar/snapshots/BasicRenderTest/fr-LU.verified.txt
new file mode 100644
index 0000000..101ef77
--- /dev/null
+++ b/tests/ModuleTests/Calendar/snapshots/BasicRenderTest/fr-LU.verified.txt
@@ -0,0 +1,12 @@
+┌────────────────────────────────────────────────┐
+│ août 2026 │
+├──────┬──────┬──────┬──────┬──────┬──────┬──────┤
+│ lun. │ mar. │ mer. │ jeu. │ ven. │ sam. │ dim. │
+├──────┼──────┼──────┼──────┼──────┼──────┼──────┤
+│ │ │ │ │ │ 1 │ 2 │
+│ 3 │ 4 │ 5 │ 6 │ 7 │ 8 │ 9 │
+│ 10 │ 11 │ 12 │ 13 │ 14 │ 15 │ 16 │
+│ 17 │ 18 │ 19 │ 20 │ 21 │ 22 │ 23 │
+│ 24 │ 25 │ 26 │ 27 │ 28 │ 29 │ 30 │
+│ 31 │ │ │ │ │ │ │
+└──────┴──────┴──────┴──────┴──────┴──────┴──────┘
\ No newline at end of file
diff --git a/tests/ModuleTests/Calendar/snapshots/BasicRenderTest/nl-NL.verified.txt b/tests/ModuleTests/Calendar/snapshots/BasicRenderTest/nl-NL.verified.txt
new file mode 100644
index 0000000..1aac604
--- /dev/null
+++ b/tests/ModuleTests/Calendar/snapshots/BasicRenderTest/nl-NL.verified.txt
@@ -0,0 +1,12 @@
+┌──────────────────────────────────┐
+│ augustus 2026 │
+├────┬────┬────┬────┬────┬────┬────┤
+│ ma │ di │ wo │ do │ vr │ za │ zo │
+├────┼────┼────┼────┼────┼────┼────┤
+│ │ │ │ │ │ 1 │ 2 │
+│ 3 │ 4 │ 5 │ 6 │ 7 │ 8 │ 9 │
+│ 10 │ 11 │ 12 │ 13 │ 14 │ 15 │ 16 │
+│ 17 │ 18 │ 19 │ 20 │ 21 │ 22 │ 23 │
+│ 24 │ 25 │ 26 │ 27 │ 28 │ 29 │ 30 │
+│ 31 │ │ │ │ │ │ │
+└────┴────┴────┴────┴────┴────┴────┘
\ No newline at end of file
diff --git a/tests/ModuleTests/Calendar/snapshots/BasicRenderTest/te-IN.verified.txt b/tests/ModuleTests/Calendar/snapshots/BasicRenderTest/te-IN.verified.txt
new file mode 100644
index 0000000..5568a20
--- /dev/null
+++ b/tests/ModuleTests/Calendar/snapshots/BasicRenderTest/te-IN.verified.txt
@@ -0,0 +1,12 @@
+┌───────────────────────────────────────────────────────┐
+│ ఆగస్టు 2026 │
+├───────┬───────┬───────┬───────┬───────┬───────┬───────┤
+│ సోమ │ మంగళ │ బుధ │ గురు │ శుక్ర │ శని │ ఆది │
+├───────┼───────┼───────┼───────┼───────┼───────┼───────┤
+│ │ │ │ │ │ 1 │ 2 │
+│ 3 │ 4 │ 5 │ 6 │ 7 │ 8 │ 9 │
+│ 10 │ 11 │ 12 │ 13 │ 14 │ 15 │ 16 │
+│ 17 │ 18 │ 19 │ 20 │ 21 │ 22 │ 23 │
+│ 24 │ 25 │ 26 │ 27 │ 28 │ 29 │ 30 │
+│ 31 │ │ │ │ │ │ │
+└───────┴───────┴───────┴───────┴───────┴───────┴───────┘
\ No newline at end of file
diff --git a/tests/ModuleTests/Calendar/snapshots/MarkedDayRenderTests/DefaultMarkedDayRender.verified.txt b/tests/ModuleTests/Calendar/snapshots/MarkedDayRenderTests/DefaultMarkedDayRender.verified.txt
new file mode 100644
index 0000000..4181cc1
--- /dev/null
+++ b/tests/ModuleTests/Calendar/snapshots/MarkedDayRenderTests/DefaultMarkedDayRender.verified.txt
@@ -0,0 +1,12 @@
+┌─────────────────────────────────────────┐
+│ August 2026 │
+├─────┬─────┬─────┬─────┬─────┬─────┬─────┤
+│ Mon │ Tue │ Wed │ Thu │ Fri │ Sat │ Sun │
+├─────┼─────┼─────┼─────┼─────┼─────┼─────┤
+│ │ │ │ │ │ x │ x │
+│ x │ x │ x │ x │ x │ x │ x │
+│ x │ x │ x │ x │ x │ x │ 16 │
+│ 17 │ 18 │ 19 │ 20 │ 21 │ 22 │ 23 │
+│ 24 │ 25 │ 26 │ 27 │ 28 │ 29 │ 30 │
+│ 31 │ │ │ │ │ │ │
+└─────┴─────┴─────┴─────┴─────┴─────┴─────┘
\ No newline at end of file
diff --git a/tests/ModuleTests/Calendar/snapshots/MarkedDayRenderTests/DoubleWideMarkedDayRender.verified.txt b/tests/ModuleTests/Calendar/snapshots/MarkedDayRenderTests/DoubleWideMarkedDayRender.verified.txt
new file mode 100644
index 0000000..cc27f4f
--- /dev/null
+++ b/tests/ModuleTests/Calendar/snapshots/MarkedDayRenderTests/DoubleWideMarkedDayRender.verified.txt
@@ -0,0 +1,13 @@
+Double-wide characters will display incorrectly but still tested until rendering can be improved
+┌─────────────────────────────────────────┐
+│ August 2026 │
+├─────┬─────┬─────┬─────┬─────┬─────┬─────┤
+│ Mon │ Tue │ Wed │ Thu │ Fri │ Sat │ Sun │
+├─────┼─────┼─────┼─────┼─────┼─────┼─────┤
+│ │ │ │ │ │ 火 │ 火 │
+│ 火 │ 火 │ 火 │ 火 │ 火 │ 火 │ 火 │
+│ 火 │ 火 │ 火 │ 火 │ 火 │ 火 │ 16 │
+│ 17 │ 18 │ 19 │ 20 │ 21 │ 22 │ 23 │
+│ 24 │ 25 │ 26 │ 27 │ 28 │ 29 │ 30 │
+│ 31 │ │ │ │ │ │ │
+└─────┴─────┴─────┴─────┴─────┴─────┴─────┘
diff --git a/tests/ModuleTests/Calendar/snapshots/MarkedDayRenderTests/EmojiMarkedDayRender.verified.txt b/tests/ModuleTests/Calendar/snapshots/MarkedDayRenderTests/EmojiMarkedDayRender.verified.txt
new file mode 100644
index 0000000..a02a725
--- /dev/null
+++ b/tests/ModuleTests/Calendar/snapshots/MarkedDayRenderTests/EmojiMarkedDayRender.verified.txt
@@ -0,0 +1,13 @@
+Emojis will display incorrectly in text but output correctly in terminals with UTF-8 encoding.
+┌─────────────────────────────────────────┐
+│ August 2026 │
+├─────┬─────┬─────┬─────┬─────┬─────┬─────┤
+│ Mon │ Tue │ Wed │ Thu │ Fri │ Sat │ Sun │
+├─────┼─────┼─────┼─────┼─────┼─────┼─────┤
+│ │ │ │ │ │ 🤫 │ 🤫 │
+│ 🤫 │ 🤫 │ 🤫 │ 🤫 │ 🤫 │ 🤫 │ 🤫 │
+│ 🤫 │ 🤫 │ 🤫 │ 🤫 │ 🤫 │ 🤫 │ 16 │
+│ 17 │ 18 │ 19 │ 20 │ 21 │ 22 │ 23 │
+│ 24 │ 25 │ 26 │ 27 │ 28 │ 29 │ 30 │
+│ 31 │ │ │ │ │ │ │
+└─────┴─────┴─────┴─────┴─────┴─────┴─────┘
diff --git a/tests/ModuleTests/Calendar/snapshots/MarkedDayRenderTests/LongMarkedDayRender.verified.txt b/tests/ModuleTests/Calendar/snapshots/MarkedDayRenderTests/LongMarkedDayRender.verified.txt
new file mode 100644
index 0000000..a8ebbf6
--- /dev/null
+++ b/tests/ModuleTests/Calendar/snapshots/MarkedDayRenderTests/LongMarkedDayRender.verified.txt
@@ -0,0 +1,12 @@
+┌──────────────────────────────────────────────────────────────┐
+│ August 2026 │
+├────────┬────────┬────────┬────────┬────────┬────────┬────────┤
+│ Mon │ Tue │ Wed │ Thu │ Fri │ Sat │ Sun │
+├────────┼────────┼────────┼────────┼────────┼────────┼────────┤
+│ │ │ │ │ │ passed │ passed │
+│ passed │ passed │ passed │ passed │ passed │ passed │ passed │
+│ passed │ passed │ passed │ passed │ passed │ passed │ 16 │
+│ 17 │ 18 │ 19 │ 20 │ 21 │ 22 │ 23 │
+│ 24 │ 25 │ 26 │ 27 │ 28 │ 29 │ 30 │
+│ 31 │ │ │ │ │ │ │
+└────────┴────────┴────────┴────────┴────────┴────────┴────────┘
\ No newline at end of file
diff --git a/tests/ModuleTests/Calendar/snapshots/MarkedDayRenderTests/SimpleMarkedDayRender.verified.txt b/tests/ModuleTests/Calendar/snapshots/MarkedDayRenderTests/SimpleMarkedDayRender.verified.txt
new file mode 100644
index 0000000..f546aac
--- /dev/null
+++ b/tests/ModuleTests/Calendar/snapshots/MarkedDayRenderTests/SimpleMarkedDayRender.verified.txt
@@ -0,0 +1,12 @@
+┌─────────────────────────────────────────┐
+│ August 2026 │
+├─────┬─────┬─────┬─────┬─────┬─────┬─────┤
+│ Mon │ Tue │ Wed │ Thu │ Fri │ Sat │ Sun │
+├─────┼─────┼─────┼─────┼─────┼─────┼─────┤
+│ │ │ │ │ │ | │ | │
+│ | │ | │ | │ | │ | │ | │ | │
+│ | │ | │ | │ | │ | │ | │ 16 │
+│ 17 │ 18 │ 19 │ 20 │ 21 │ 22 │ 23 │
+│ 24 │ 25 │ 26 │ 27 │ 28 │ 29 │ 30 │
+│ 31 │ │ │ │ │ │ │
+└─────┴─────┴─────┴─────┴─────┴─────┴─────┘
\ No newline at end of file
diff --git a/tests/ModuleTests/Calendar/snapshots/StartDayOfWeekTests/StartOfDayRender.verified.txt b/tests/ModuleTests/Calendar/snapshots/StartDayOfWeekTests/StartOfDayRender.verified.txt
new file mode 100644
index 0000000..8393453
--- /dev/null
+++ b/tests/ModuleTests/Calendar/snapshots/StartDayOfWeekTests/StartOfDayRender.verified.txt
@@ -0,0 +1,86 @@
+Start Day: Sunday
+┌─────────────────────────────────────────┐
+│ August 2026 │
+├─────┬─────┬─────┬─────┬─────┬─────┬─────┤
+│ Sun │ Mon │ Tue │ Wed │ Thu │ Fri │ Sat │
+├─────┼─────┼─────┼─────┼─────┼─────┼─────┤
+│ │ │ │ │ │ │ 1 │
+│ 2 │ 3 │ 4 │ 5 │ 6 │ 7 │ 8 │
+│ 9 │ 10 │ 11 │ 12 │ 13 │ 14 │ 15 │
+│ 16 │ 17 │ 18 │ 19 │ 20 │ 21 │ 22 │
+│ 23 │ 24 │ 25 │ 26 │ 27 │ 28 │ 29 │
+│ 30 │ 31 │ │ │ │ │ │
+└─────┴─────┴─────┴─────┴─────┴─────┴─────┘
+Start Day: Monday
+┌─────────────────────────────────────────┐
+│ August 2026 │
+├─────┬─────┬─────┬─────┬─────┬─────┬─────┤
+│ Mon │ Tue │ Wed │ Thu │ Fri │ Sat │ Sun │
+├─────┼─────┼─────┼─────┼─────┼─────┼─────┤
+│ │ │ │ │ │ 1 │ 2 │
+│ 3 │ 4 │ 5 │ 6 │ 7 │ 8 │ 9 │
+│ 10 │ 11 │ 12 │ 13 │ 14 │ 15 │ 16 │
+│ 17 │ 18 │ 19 │ 20 │ 21 │ 22 │ 23 │
+│ 24 │ 25 │ 26 │ 27 │ 28 │ 29 │ 30 │
+│ 31 │ │ │ │ │ │ │
+└─────┴─────┴─────┴─────┴─────┴─────┴─────┘
+Start Day: Tuesday
+┌─────────────────────────────────────────┐
+│ August 2026 │
+├─────┬─────┬─────┬─────┬─────┬─────┬─────┤
+│ Tue │ Wed │ Thu │ Fri │ Sat │ Sun │ Mon │
+├─────┼─────┼─────┼─────┼─────┼─────┼─────┤
+│ │ │ │ │ 1 │ 2 │ 3 │
+│ 4 │ 5 │ 6 │ 7 │ 8 │ 9 │ 10 │
+│ 11 │ 12 │ 13 │ 14 │ 15 │ 16 │ 17 │
+│ 18 │ 19 │ 20 │ 21 │ 22 │ 23 │ 24 │
+│ 25 │ 26 │ 27 │ 28 │ 29 │ 30 │ 31 │
+└─────┴─────┴─────┴─────┴─────┴─────┴─────┘
+Start Day: Wednesday
+┌─────────────────────────────────────────┐
+│ August 2026 │
+├─────┬─────┬─────┬─────┬─────┬─────┬─────┤
+│ Wed │ Thu │ Fri │ Sat │ Sun │ Mon │ Tue │
+├─────┼─────┼─────┼─────┼─────┼─────┼─────┤
+│ │ │ │ 1 │ 2 │ 3 │ 4 │
+│ 5 │ 6 │ 7 │ 8 │ 9 │ 10 │ 11 │
+│ 12 │ 13 │ 14 │ 15 │ 16 │ 17 │ 18 │
+│ 19 │ 20 │ 21 │ 22 │ 23 │ 24 │ 25 │
+│ 26 │ 27 │ 28 │ 29 │ 30 │ 31 │ │
+└─────┴─────┴─────┴─────┴─────┴─────┴─────┘
+Start Day: Thursday
+┌─────────────────────────────────────────┐
+│ August 2026 │
+├─────┬─────┬─────┬─────┬─────┬─────┬─────┤
+│ Thu │ Fri │ Sat │ Sun │ Mon │ Tue │ Wed │
+├─────┼─────┼─────┼─────┼─────┼─────┼─────┤
+│ │ │ 1 │ 2 │ 3 │ 4 │ 5 │
+│ 6 │ 7 │ 8 │ 9 │ 10 │ 11 │ 12 │
+│ 13 │ 14 │ 15 │ 16 │ 17 │ 18 │ 19 │
+│ 20 │ 21 │ 22 │ 23 │ 24 │ 25 │ 26 │
+│ 27 │ 28 │ 29 │ 30 │ 31 │ │ │
+└─────┴─────┴─────┴─────┴─────┴─────┴─────┘
+Start Day: Friday
+┌─────────────────────────────────────────┐
+│ August 2026 │
+├─────┬─────┬─────┬─────┬─────┬─────┬─────┤
+│ Fri │ Sat │ Sun │ Mon │ Tue │ Wed │ Thu │
+├─────┼─────┼─────┼─────┼─────┼─────┼─────┤
+│ │ 1 │ 2 │ 3 │ 4 │ 5 │ 6 │
+│ 7 │ 8 │ 9 │ 10 │ 11 │ 12 │ 13 │
+│ 14 │ 15 │ 16 │ 17 │ 18 │ 19 │ 20 │
+│ 21 │ 22 │ 23 │ 24 │ 25 │ 26 │ 27 │
+│ 28 │ 29 │ 30 │ 31 │ │ │ │
+└─────┴─────┴─────┴─────┴─────┴─────┴─────┘
+Start Day: Saturday
+┌─────────────────────────────────────────┐
+│ August 2026 │
+├─────┬─────┬─────┬─────┬─────┬─────┬─────┤
+│ Sat │ Sun │ Mon │ Tue │ Wed │ Thu │ Fri │
+├─────┼─────┼─────┼─────┼─────┼─────┼─────┤
+│ 1 │ 2 │ 3 │ 4 │ 5 │ 6 │ 7 │
+│ 8 │ 9 │ 10 │ 11 │ 12 │ 13 │ 14 │
+│ 15 │ 16 │ 17 │ 18 │ 19 │ 20 │ 21 │
+│ 22 │ 23 │ 24 │ 25 │ 26 │ 27 │ 28 │
+│ 29 │ 30 │ 31 │ │ │ │ │
+└─────┴─────┴─────┴─────┴─────┴─────┴─────┘
diff --git a/tests/ModuleTests/ModuleTests.csproj b/tests/ModuleTests/ModuleTests.csproj
new file mode 100644
index 0000000..7343cfd
--- /dev/null
+++ b/tests/ModuleTests/ModuleTests.csproj
@@ -0,0 +1,25 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+ Exe
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/ModuleTests/TestConstants.cs b/tests/ModuleTests/TestConstants.cs
new file mode 100644
index 0000000..e32b3cd
--- /dev/null
+++ b/tests/ModuleTests/TestConstants.cs
@@ -0,0 +1,6 @@
+namespace ModuleTests;
+
+public class TestConstants
+{
+ public const string SnapshotFolderName = "Snapshots";
+}
\ No newline at end of file
diff --git a/tests/ModuleTests/TestDataEnumerator.cs b/tests/ModuleTests/TestDataEnumerator.cs
new file mode 100644
index 0000000..92937f5
--- /dev/null
+++ b/tests/ModuleTests/TestDataEnumerator.cs
@@ -0,0 +1,15 @@
+using System.Collections;
+
+namespace ModuleTests;
+
+public abstract class TestDataEnumerator : IEnumerable>
+{
+ protected IEnumerable Data { get; init; } = [];
+
+ public IEnumerator> GetEnumerator()
+ {
+ return Data.Select(data => new TheoryDataRow(data)).GetEnumerator();
+ }
+
+ IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
+}
\ No newline at end of file