diff --git a/.build.Nuke/Build.Compile.cs b/.build.Nuke/Build.Compile.cs index 718ee69e81ff1733a4f46273dfac00b108de023f..b07b145ffac266baf1465a99f7096b28f3bf439e 100644 --- a/.build.Nuke/Build.Compile.cs +++ b/.build.Nuke/Build.Compile.cs @@ -2,6 +2,7 @@ using Nuke.Common; using Nuke.Common.IO; using Nuke.Common.Tools.DotNet; using Nuke.Common.Utilities.Collections; +using Serilog; using static Nuke.Common.Tools.DotNet.DotNetTasks; namespace SuCoS; @@ -12,16 +13,17 @@ namespace SuCoS; /// sealed partial class Build : NukeBuild { - [Parameter("output-directory (default: ./output)")] - readonly string outputDirectory = RootDirectory / "output"; - Target Clean => _ => _ .Executes(() => { - sourceDirectory.GlobDirectories("**/bin", "**/obj").ForEach( + sourceDirectory.GlobDirectories("**/bin", "**/obj", "**/output").ForEach( + (path) => path.DeleteDirectory() + ); + testDirectory.GlobDirectories("**/bin", "**/obj", "**/output").ForEach( (path) => path.DeleteDirectory() ); - PublishDirectory.CreateOrCleanDirectory(); + PublishDirectory.DeleteDirectory(); + coverageDirectory.DeleteDirectory(); }); Target Restore => _ => _ @@ -36,11 +38,12 @@ sealed partial class Build : NukeBuild .DependsOn(Restore) .Executes(() => { + Log.Debug("Configuration {Configuration}", configurationSet); + Log.Debug("configuration {configuration}", configuration); DotNetBuild(s => s .SetNoLogo(true) .SetProjectFile(solution) - .SetConfiguration(configuration) - .SetOutputDirectory(outputDirectory) + .SetConfiguration(configurationSet) .EnableNoRestore() ); }); diff --git a/.build.Nuke/Build.Publish.cs b/.build.Nuke/Build.Publish.cs index 13bf9e700ac6e247b113773c973999aa155330fc..e3836c44f26e83945bc0a4ab144dde88eea41db8 100644 --- a/.build.Nuke/Build.Publish.cs +++ b/.build.Nuke/Build.Publish.cs @@ -34,7 +34,7 @@ sealed partial class Build : NukeBuild DotNetPublish(s => s .SetNoLogo(true) .SetProject(solution) - .SetConfiguration(configuration) + .SetConfiguration(configurationSet) .SetOutput(PublishDirectory) .SetRuntime(runtimeIdentifier) .SetSelfContained(publishSelfContained) diff --git a/.build.Nuke/Build.Solution.cs b/.build.Nuke/Build.Solution.cs index 40877b60743388e0259d49920257c09cce6cf458..2612b15775c93f6be4ad6c8966f7cfca7c664032 100644 --- a/.build.Nuke/Build.Solution.cs +++ b/.build.Nuke/Build.Solution.cs @@ -11,7 +11,8 @@ namespace SuCoS; sealed partial class Build : NukeBuild { [Parameter("Configuration to build - Default is 'Debug' (local) or 'Release' (server)")] - readonly Configuration configuration = IsLocalBuild ? Configuration.Debug : Configuration.Release; + readonly string configuration; + string configurationSet => configuration ?? (IsLocalBuild ? Configuration.Debug : Configuration.Release); [Solution] readonly Solution solution; diff --git a/.build.Nuke/Build.Test.cs b/.build.Nuke/Build.Test.cs new file mode 100644 index 0000000000000000000000000000000000000000..dccde7febff7da5d35f5086291a9ad801ce14589 --- /dev/null +++ b/.build.Nuke/Build.Test.cs @@ -0,0 +1,61 @@ +using Nuke.Common; +using Nuke.Common.Tools.DotNet; +using Nuke.Common.Tools.OpenCover; +using static Nuke.Common.Tools.ReportGenerator.ReportGeneratorTasks; +using Nuke.Common.Tools.ReportGenerator; +using Nuke.Common.IO; +using Nuke.Common.Tools.Coverlet; +using static Nuke.Common.Tools.Coverlet.CoverletTasks; +using static Nuke.Common.IO.FileSystemTasks; + +namespace SuCoS; + +/// +/// This is the main build file for the project. +/// This partial is responsible for the build process. +/// +sealed partial class Build : NukeBuild +{ + AbsolutePath TestDLL => testDirectory / "bin" / "Debug" / "net7.0"; + AbsolutePath testDirectory => RootDirectory / "test"; + AbsolutePath TestSiteDirectory => RootDirectory / "test" / ".TestSites"; + AbsolutePath TestOutputDirectory => TestDLL / ".TestSites"; + AbsolutePath coverageDirectory => RootDirectory / "coverage-results"; + AbsolutePath ReportDirectory => coverageDirectory / "report"; + AbsolutePath CoverageResultDirectory => coverageDirectory / "coverage"; + AbsolutePath CoverageResultFile => CoverageResultDirectory / "coverage.cobertura.xml"; + + Target PrepareTestFiles => _ => _ + .After(Clean) + .Executes(() => + { + TestOutputDirectory.CreateOrCleanDirectory(); + CopyDirectoryRecursively(TestSiteDirectory, TestOutputDirectory, DirectoryExistsPolicy.Merge); + }); + + Target Test => _ => _ + .DependsOn(Compile, PrepareTestFiles) + .Executes(() => + { + CoverageResultDirectory.CreateDirectory(); + Coverlet(s => s + .SetTarget("dotnet") + .SetTargetArgs("test --no-build --no-restore") + .SetAssembly(TestDLL / "test.dll") + // .SetThreshold(75) + .SetOutput(CoverageResultFile) + .SetFormat(CoverletOutputFormat.opencover)); + }); + + Target TestReport => _ => _ + .DependsOn(Test) + .AssuredAfterFailure() + .Executes(() => + { + ReportDirectory.CreateDirectory(); + ReportGenerator(s => s + .SetTargetDirectory(ReportDirectory) + .SetReportTypes(new ReportTypes[] { ReportTypes.Html }) + .SetReports(CoverageResultFile)); + }); +} diff --git a/.build.Nuke/_build_nuke.csproj b/.build.Nuke/_build_nuke.csproj index f5b93a471dfec58c075cd00a491de0734bb919e5..0b25bea5df851645ffcf896d53ea0220d8084cc0 100644 --- a/.build.Nuke/_build_nuke.csproj +++ b/.build.Nuke/_build_nuke.csproj @@ -11,11 +11,14 @@ + + + diff --git a/.gitignore b/.gitignore index e6195539daf506ebba61125d87e3d44524a30793..0de7bb9b2d0bc3d6b4c16b3316312fc91266b19d 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,4 @@ artifacts/ output/ project.fragment.lock.json project.lock.json +**/coverage-results/ \ No newline at end of file diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index d03173709aa1fde943abb712d483e13c22b2f1c2..d8c2684e845cb5f38dc104d508315c4e45f984f1 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,7 +1,7 @@ image: mcr.microsoft.com/dotnet/sdk:7.0 stages: - - build + - build-test - check-and-create-release - publish @@ -27,22 +27,22 @@ services: - docker:dind # build the project on every commit -build: +test: <<: *dotnet_nuke_template - stage: build + stage: build-test except: - tags - schedules script: - | - ./build.sh Compile \ - --output-directory "./output" + ./build.sh TestReport \ + --configuration "Debug" artifacts: paths: - - output/* + - coverage-results/report/* -# # check if there is new commits, if so, create a tag and a release -# # this will trigger the publish stage "publish" +# check if there is new commits, if so, create a tag and a release +# this will trigger the publish stage "publish" check-and-create-release: <<: *dotnet_nuke_template stage: check-and-create-release diff --git a/.nuke/build.schema.json b/.nuke/build.schema.json index b0c262ba472312399d0afbc8075ea2b325358c4c..7d2aca0300f1d9c02a06af381d66d4616e31a833 100644 --- a/.nuke/build.schema.json +++ b/.nuke/build.schema.json @@ -8,11 +8,7 @@ "properties": { "configuration": { "type": "string", - "description": "Configuration to build - Default is 'Debug' (local) or 'Release' (server)", - "enum": [ - "k__BackingField", - "k__BackingField" - ] + "description": "Configuration to build - Default is 'Debug' (local) or 'Release' (server)" }, "containerDefaultRID": { "type": "string", @@ -63,10 +59,6 @@ "type": "boolean", "description": "Disables displaying the NUKE logo" }, - "outputDirectory": { - "type": "string", - "description": "output-directory (default: ./output)" - }, "packageName": { "type": "string", "description": "package-name (default: SuCoS)" @@ -123,9 +115,12 @@ "CreatePackage", "GitLabCreateRelease", "GitLabCreateTag", + "PrepareTestFiles", "Publish", "Restore", - "ShowCurrentVersion" + "ShowCurrentVersion", + "Test", + "TestReport" ] } }, @@ -146,9 +141,12 @@ "CreatePackage", "GitLabCreateRelease", "GitLabCreateTag", + "PrepareTestFiles", "Publish", "Restore", - "ShowCurrentVersion" + "ShowCurrentVersion", + "Test", + "TestReport" ] } }, diff --git a/SuCoS.sln b/SuCoS.sln index 7100ee9b00825278fe23bfe3b519819bb8d8895f..45adce0ff8022c7992503796edb48a0b5afc6265 100644 --- a/SuCoS.sln +++ b/SuCoS.sln @@ -9,6 +9,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SuCoS", "source\SuCoS.cspro EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "_build_nuke", ".build.Nuke\_build_nuke.csproj", "{26DB04F6-DA88-43D7-8F4B-535D4D68C24E}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "test", "test\test.csproj", "{F3D789FD-6AC5-4A45-B9AC-079035F5909C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -24,6 +26,10 @@ Global {2395B57B-24D7-47AB-B400-7AC6C1FCAFA1}.Debug|Any CPU.Build.0 = Debug|Any CPU {2395B57B-24D7-47AB-B400-7AC6C1FCAFA1}.Release|Any CPU.ActiveCfg = Release|Any CPU {2395B57B-24D7-47AB-B400-7AC6C1FCAFA1}.Release|Any CPU.Build.0 = Release|Any CPU + {F3D789FD-6AC5-4A45-B9AC-079035F5909C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F3D789FD-6AC5-4A45-B9AC-079035F5909C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F3D789FD-6AC5-4A45-B9AC-079035F5909C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F3D789FD-6AC5-4A45-B9AC-079035F5909C}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {2395B57B-24D7-47AB-B400-7AC6C1FCAFA1} = {1A575294-ABB9-4BCF-8FF7-9981A26A55F9} diff --git a/source/BaseGeneratorCommand.cs b/source/BaseGeneratorCommand.cs index e3713a821c44616f9b600443f8376941ebc53e0b..0231d33becf20a3f09beec654233326f52974757 100644 --- a/source/BaseGeneratorCommand.cs +++ b/source/BaseGeneratorCommand.cs @@ -4,6 +4,7 @@ using System.Threading.Tasks; using Fluid; using Fluid.Values; using Serilog; +using SuCoS.Helper; using SuCoS.Models; using SuCoS.Parser; @@ -32,22 +33,34 @@ public abstract class BaseGeneratorCommand /// /// The stopwatch reporter. /// - protected readonly StopwatchReporter stopwatch = new(); + protected readonly StopwatchReporter stopwatch; + + /// + /// The logger (Serilog). + /// + protected ILogger logger; /// /// Initializes a new instance of the class. /// /// The generate options. - protected BaseGeneratorCommand(IGenerateOptions options) + /// The logger instance. Injectable for testing + protected BaseGeneratorCommand(IGenerateOptions options, ILogger logger) { if (options is null) { throw new ArgumentNullException(nameof(options)); } + if (logger is null) + { + throw new ArgumentNullException(nameof(logger)); + } + this.logger = logger; + stopwatch = new(logger); - Log.Information("Source path: {source}", propertyValue: options.Source); + logger.Information("Source path: {source}", propertyValue: options.Source); - site = SiteHelper.Init(configFile, options, frontmatterParser, WhereParamsFilter, stopwatch); + site = SiteHelper.Init(configFile, options, frontmatterParser, WhereParamsFilter, logger, stopwatch); } /// diff --git a/source/BuildCommand.cs b/source/BuildCommand.cs index 0930c98b5256310f6792be07105c9addc6854a27..5cad285191fe60b2068cde81ba5d3abc338b1859 100644 --- a/source/BuildCommand.cs +++ b/source/BuildCommand.cs @@ -11,22 +11,20 @@ namespace SuCoS; /// public class BuildCommand : BaseGeneratorCommand { - private readonly BuildOptions options; - /// /// Entry point of the build command. It will be called by the main program /// in case the build command is invoked (which is by default). /// - /// - public BuildCommand(BuildOptions options) : base(options: options) + /// Command line options + /// The logger instance. Injectable for testing + public BuildCommand(BuildOptions options, ILogger logger) : base(options, logger) { if (options is null) { throw new ArgumentNullException(nameof(options)); } - this.options = options; - Log.Information("Output path: {output}", options.Output); + logger.Information("Output path: {output}", options.Output); // Generate the site pages CreateOutputFiles(); @@ -64,7 +62,7 @@ public class BuildCommand : BaseGeneratorCommand File.WriteAllText(outputAbsolutePath, result); // Log - Log.Debug("Page created: {Permalink}", frontmatter.Permalink); + logger.Debug("Page created: {Permalink}", frontmatter.Permalink); // Use interlocked to safely increment the counter in a multi-threaded environment _ = Interlocked.Increment(ref pagesCreated); diff --git a/source/Helpers/FileUtils.cs b/source/Helpers/FileUtils.cs index 584cc4854662d0a250f00f34d5260c2ed1966bd6..b17034e7a80ca0f68b504a0d1008b30385a1a463 100644 --- a/source/Helpers/FileUtils.cs +++ b/source/Helpers/FileUtils.cs @@ -3,7 +3,7 @@ using System.Collections.Generic; using System.IO; using SuCoS.Models; -namespace SuCoS; +namespace SuCoS.Helper; /// /// Helper methods for scanning files. diff --git a/source/Helpers/SiteHelper.cs b/source/Helpers/SiteHelper.cs index cbdcc97de7d4da4c7eb1677a7cfdebc6ac01049d..7c860f39037a28b64c8e4433d7b7a48df179a6d9 100644 --- a/source/Helpers/SiteHelper.cs +++ b/source/Helpers/SiteHelper.cs @@ -2,10 +2,11 @@ using System; using System.IO; using Fluid; using Microsoft.Extensions.FileProviders; +using Serilog; using SuCoS.Models; using SuCoS.Parser; -namespace SuCoS; +namespace SuCoS.Helper; /// /// Helper methods for scanning files. @@ -20,8 +21,9 @@ public static class SiteHelper /// The frontmatter parser. /// The site settings file. /// The method to be used in the whereParams. + /// The logger instance. Injectable for testing /// The site settings. - public static Site ParseSettings(string configFile, IGenerateOptions options, IFrontmatterParser frontmatterParser, FilterDelegate whereParamsFilter) + public static Site ParseSettings(string configFile, IGenerateOptions options, IFrontmatterParser frontmatterParser, FilterDelegate whereParamsFilter, ILogger logger) { if (options is null) { @@ -44,15 +46,13 @@ public static class SiteHelper var fileContent = File.ReadAllText(filePath); var site = frontmatterParser.ParseSiteSettings(fileContent); + site.Logger = logger; site.options = options; site.SourceDirectoryPath = options.Source; site.OutputPath = options.Output; // Liquid template options, needed to theme the content // but also parse URLs - site.TemplateOptions.MemberAccessStrategy.Register(); - site.TemplateOptions.MemberAccessStrategy.Register(); - site.TemplateOptions.MemberAccessStrategy.Register(); site.TemplateOptions.Filters.AddFilter("whereParams", whereParamsFilter); if (site is null) @@ -72,7 +72,7 @@ public static class SiteHelper /// Creates the pages dictionary. /// /// - public static Site Init(string configFile, IGenerateOptions options, IFrontmatterParser frontmatterParser, FilterDelegate whereParamsFilter, StopwatchReporter stopwatch) + public static Site Init(string configFile, IGenerateOptions options, IFrontmatterParser frontmatterParser, FilterDelegate whereParamsFilter, ILogger logger, StopwatchReporter stopwatch) { if (stopwatch is null) { @@ -82,7 +82,7 @@ public static class SiteHelper Site site; try { - site = SiteHelper.ParseSettings(configFile, options, frontmatterParser, whereParamsFilter); + site = SiteHelper.ParseSettings(configFile, options, frontmatterParser, whereParamsFilter, logger); } catch { diff --git a/source/Helpers/StopwatchReporter.cs b/source/Helpers/StopwatchReporter.cs index 39ebd9149ea6b9aab7892d780f00c55b22e390df..5a691f7b6e1be37172b8ebe87be26904e2cf3019 100644 --- a/source/Helpers/StopwatchReporter.cs +++ b/source/Helpers/StopwatchReporter.cs @@ -5,7 +5,7 @@ using System.Globalization; using System.Linq; using Serilog; -namespace SuCoS; +namespace SuCoS.Helper; /// /// This class is used to report the time taken to execute @@ -14,14 +14,16 @@ namespace SuCoS; /// public class StopwatchReporter { + private readonly ILogger logger; private readonly Dictionary stopwatches; private readonly Dictionary itemCounts; /// /// Constructor /// - public StopwatchReporter() + public StopwatchReporter(ILogger logger) { + this.logger = logger; stopwatches = new Dictionary(); itemCounts = new Dictionary(); } @@ -102,6 +104,6 @@ Total {totalDurationAllSteps} ms ═════════════════════════════════════════════"; // Log the report - Log.Information(report, siteTitle); + logger.Information(report, siteTitle); } } \ No newline at end of file diff --git a/source/Helpers/Urlizer.cs b/source/Helpers/Urlizer.cs index d371adb2ba42c1bbc5c0d3966338372278375149..715e0c08ab661a6ecc390d4bcd232ce54e4e9fe8 100644 --- a/source/Helpers/Urlizer.cs +++ b/source/Helpers/Urlizer.cs @@ -1,16 +1,19 @@ using System; +using System.Collections.Generic; using System.Globalization; using System.Text.RegularExpressions; -namespace SuCoS; +namespace SuCoS.Helper; /// /// Helper class to convert a string to a URL-friendly string. /// public static partial class Urlizer { - [GeneratedRegex(@"[^a-z0-9.]")] - private static partial Regex UrlizeRegex(); + [GeneratedRegex(@"[^a-zA-Z0-9]+")] + private static partial Regex UrlizeRegexAlpha(); + [GeneratedRegex(@"[^a-zA-Z0-9.]+")] + private static partial Regex UrlizeRegexAlphaDot(); /// /// Converts a string to a URL-friendly string. @@ -22,25 +25,19 @@ public static partial class Urlizer /// public static string Urlize(string title, UrlizerOptions? options = null) { - if (title == null) - { - throw new ArgumentNullException(nameof(title)); - } + title ??= ""; options ??= new UrlizerOptions(); // Use default options if not provided - var cleanedTitle = title; + var cleanedTitle = !options.LowerCase ? title : title.ToLower(CultureInfo.CurrentCulture); - // Apply culture-specific case conversion if enabled - if (options.LowerCase) - { - cleanedTitle = cleanedTitle.ToLower(CultureInfo.CurrentCulture); - } + var replacementChar = options.ReplacementChar ?? '\0'; + var replacementCharString = options.ReplacementChar.ToString() ?? ""; // Remove non-alphanumeric characters and replace spaces with the replacement character - cleanedTitle = UrlizeRegex() - .Replace(cleanedTitle, options.ReplacementChar.ToString()) - .Trim(options.ReplacementChar); + cleanedTitle = (options.ReplaceDot ? UrlizeRegexAlpha() : UrlizeRegexAlphaDot()) + .Replace(cleanedTitle, replacementCharString) + .Trim(replacementChar); return cleanedTitle; } @@ -53,12 +50,17 @@ public static partial class Urlizer /// public static string UrlizePath(string path, UrlizerOptions? options = null) { - var items = (path ?? string.Empty).Split("/"); + var pathString = (path ?? string.Empty); + var items = pathString.Split("/"); + var result = new List(); for (var i = 0; i < items.Length; i++) { - items[i] = Urlize(items[i], options); + if (!string.IsNullOrEmpty(items[i])) + { + result.Add(Urlize(items[i], options)); + } } - return string.Join("/", items); + return (pathString.StartsWith('/') ? '/' : string.Empty) + string.Join('/', result); } } @@ -76,5 +78,11 @@ public class UrlizerOptions /// /// The character that will be used to replace spaces and other invalid characters. /// - public char ReplacementChar { get; set; } = '-'; + public char? ReplacementChar { get; set; } = '-'; + + /// + /// Replace dots with the replacement character. + /// Note that it will break file paths and domain names. + /// + public bool ReplaceDot { get; set; } } diff --git a/source/Models/Frontmatter.cs b/source/Models/Frontmatter.cs index 79129b5e4c6fe550c5c37faf18d4e1362f982e5b..bf5a41aa6c695a5e45ad310c74c8db3fa41af1d6 100644 --- a/source/Models/Frontmatter.cs +++ b/source/Models/Frontmatter.cs @@ -5,10 +5,9 @@ using System.IO; using System.Linq; using Fluid; using Markdig; -using Serilog; -using SuCoS.Models; +using SuCoS.Helper; -namespace SuCoS; +namespace SuCoS.Models; /// /// The meta data about each content Markdown file. @@ -138,10 +137,10 @@ public class Frontmatter : IBaseContent, IParams { get { - if (contentCacheTime is null || Site.IgnoreCacheBefore >= contentCacheTime) + if (contentCacheTime is null || Site.IgnoreCacheBefore > contentCacheTime) { contentCache = CreateContent(); - contentCacheTime = DateTime.UtcNow; + contentCacheTime = clock.UtcNow; } return contentCache!; } @@ -170,7 +169,10 @@ public class Frontmatter : IBaseContent, IParams pagesCached ??= new(); foreach (var permalink in PagesReferences) { - pagesCached.Add(Site.PagesDict[permalink]); + if (permalink is not null) + { + pagesCached.Add(Site.PagesDict[permalink]); + } } } return pagesCached; @@ -205,12 +207,12 @@ public class Frontmatter : IBaseContent, IParams /// /// Check if the page is expired /// - public bool IsDateExpired => ExpiryDate is not null && ExpiryDate >= DateTime.Now; + public bool IsDateExpired => ExpiryDate is not null && ExpiryDate <= clock.Now; /// /// Check if the page is publishable /// - public bool IsDatePublishable => GetPublishDate is null || GetPublishDate <= DateTime.Now; + public bool IsDatePublishable => GetPublishDate is null || GetPublishDate <= clock.Now; /// /// The markdown content. @@ -233,6 +235,8 @@ public class Frontmatter : IBaseContent, IParams private DateTime? GetPublishDate => PublishDate ?? Date; + private readonly ISystemClock clock; + /// /// Required. /// @@ -240,9 +244,11 @@ public class Frontmatter : IBaseContent, IParams string title, string sourcePath, Site site, + ISystemClock clock, string? sourceFileNameWithoutExtension = null, string? sourcePathDirectory = null) { + this.clock = clock; Title = title; Site = site; SourcePath = sourcePath; @@ -268,35 +274,35 @@ public class Frontmatter : IBaseContent, IParams public string CreatePermalink(string? URLforce = null) { var isIndex = SourceFileNameWithoutExtension == "index"; - string outputRelativePath; + var permaLink = string.Empty; URLforce ??= URL ?? (isIndex ? "{{ page.SourcePathDirectory }}" : "{{ page.SourcePathDirectory }}/{{ page.Title }}"); - outputRelativePath = URLforce; - - if (Site.FluidParser.TryParse(URLforce, out var template, out var error)) + try { - var context = new TemplateContext(Site.TemplateOptions) - .SetValue("page", this); - try + if (Site.FluidParser.TryParse(URLforce, out var template, out var error)) { - outputRelativePath = template.Render(context); + var context = new TemplateContext(Site.TemplateOptions) + .SetValue("page", this); + permaLink = template.Render(context); } - catch (Exception ex) + else { - Log.Error(ex, "Error converting URL: {Error}", error); + throw new FormatException(error); } } + catch (Exception ex) + { + Site.Logger?.Error(ex, "Error converting URL: {URLforce}", URLforce); + } - outputRelativePath = Urlizer.UrlizePath(outputRelativePath); - - if (!Path.IsPathRooted(outputRelativePath) && !outputRelativePath.StartsWith("/")) + if (!Path.IsPathRooted(permaLink) && !permaLink.StartsWith('/')) { - outputRelativePath = "/" + outputRelativePath; + permaLink = '/' + permaLink; } - return outputRelativePath; + return Urlizer.UrlizePath(permaLink); } /// @@ -326,7 +332,7 @@ public class Frontmatter : IBaseContent, IParams } else { - Log.Error("Error parsing theme template: {Error}", error); + Site.Logger?.Error("Error parsing theme template: {Error}", error); return string.Empty; } @@ -341,9 +347,9 @@ public class Frontmatter : IBaseContent, IParams { var fileContents = FileUtils.GetTemplate(Site.SourceThemePath, this, Site); // Theme content - if (string.IsNullOrEmpty(value: fileContents)) + if (string.IsNullOrEmpty(fileContents)) { - return Content; + return ContentPreRendered; } else if (Site.FluidParser.TryParse(fileContents, out var template, out var error)) { @@ -356,13 +362,13 @@ public class Frontmatter : IBaseContent, IParams } catch (Exception ex) { - Log.Error(ex, "Error rendering theme template: {Error}", error); + Site.Logger?.Error(ex, "Error rendering theme template: {Error}", error); return string.Empty; } } else { - Log.Error("Error parsing theme template: {Error}", error); + Site.Logger?.Error("Error parsing theme template: {Error}", error); return string.Empty; } } diff --git a/source/Models/ISystemClock.cs b/source/Models/ISystemClock.cs new file mode 100644 index 0000000000000000000000000000000000000000..bd389003fef397fc66e2864d71bf020b0d8ff043 --- /dev/null +++ b/source/Models/ISystemClock.cs @@ -0,0 +1,35 @@ +using System; + +namespace SuCoS.Models; + +/// +/// Represents an interface for accessing the system clock. +/// +public interface ISystemClock +{ + /// + /// Gets the current local date and time. + /// + DateTime Now { get; } + + /// + /// Gets the current Coordinated Universal Time (UTC). + /// + DateTime UtcNow { get; } +} + +/// +/// Represents a concrete implementation of the ISystemClock interface using the system clock. +/// +public class SystemClock : ISystemClock +{ + /// + /// Gets the current local date and time. + /// + public DateTime Now => DateTime.Now; + + /// + /// Gets the current Coordinated Universal Time (UTC). + /// + public DateTime UtcNow => DateTime.UtcNow; +} diff --git a/source/Models/Site.cs b/source/Models/Site.cs index 69470c216c4f6c904b759b7d51fe8f537431e418..cdcf70f058ad9e53ee41d71104c3537f4d23703a 100644 --- a/source/Models/Site.cs +++ b/source/Models/Site.cs @@ -7,6 +7,7 @@ using System.Threading.Tasks; using Fluid; using Markdig; using Serilog; +using SuCoS.Helper; using SuCoS.Parser; namespace SuCoS.Models; @@ -140,6 +141,11 @@ public class Site : IParams /// public readonly TemplateOptions TemplateOptions = new(); + /// + /// The logger instance. + /// + public ILogger? Logger; + /// /// The time that the older cache should be ignored. /// @@ -169,6 +175,8 @@ public class Site : IParams private List? regularPagesCache; + private readonly ISystemClock clock; + /// /// Markdig 20+ built-in extensions /// @@ -177,6 +185,26 @@ public class Site : IParams .UseAdvancedExtensions() .Build(); + /// + /// Constructor + /// + public Site() : this(new SystemClock()) + { + } + + /// + /// Constructor + /// + public Site(ISystemClock clock) + { + // Liquid template options, needed to theme the content + // but also parse URLs + TemplateOptions.MemberAccessStrategy.Register(); + TemplateOptions.MemberAccessStrategy.Register(); + + this.clock = clock; + } + /// /// Scans all markdown files in the source directory. /// @@ -227,6 +255,7 @@ public class Site : IParams if (!automaticContentCache.TryGetValue(id, out frontmatter)) { frontmatter = new( + clock: new SystemClock(), site: this, title: baseContent.Title, sourcePath: string.Empty, @@ -294,7 +323,7 @@ public class Site : IParams } catch (Exception ex) { - Log.Error(ex, "Error parsing file {file}", file.filePath); + Logger?.Error(ex, "Error parsing file {file}", file.filePath); } // Use interlocked to safely increment the counter in a multi-threaded environment @@ -322,6 +351,7 @@ public class Site : IParams Frontmatter frontmatter = new( title: Title, site: this, + clock: clock, sourcePath: Path.Combine(relativePath, "index.md"), sourceFileNameWithoutExtension: "index", sourcePathDirectory: "/" @@ -340,8 +370,13 @@ public class Site : IParams /// /// /// - private void PostProcessFrontMatter(Frontmatter frontmatter, bool overwrite = false) + public void PostProcessFrontMatter(Frontmatter frontmatter, bool overwrite = false) { + if (frontmatter is null) + { + throw new ArgumentNullException(nameof(frontmatter)); + } + frontmatter.Permalink = frontmatter.CreatePermalink(); lock (syncLockPostProcess) { diff --git a/source/Parser/YAMLParser.cs b/source/Parser/YAMLParser.cs index 48ffd89cbaed7cf41ee3df7852f74e3450b13738..7ec42e10280989bae43016ed5dd3f13d45d4cc14 100644 --- a/source/Parser/YAMLParser.cs +++ b/source/Parser/YAMLParser.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Text.RegularExpressions; +using SuCoS.Helper; using SuCoS.Models; using YamlDotNet.Serialization; using YamlDotNet.Serialization.NamingConventions; @@ -40,6 +41,7 @@ public partial class YAMLParser : IFrontmatterParser { throw new ArgumentNullException(nameof(site)); } + var dateTime = new SystemClock(); Frontmatter? frontmatter = null; var match = YAMLRegex().Match(fileContent); @@ -80,6 +82,7 @@ public partial class YAMLParser : IFrontmatterParser frontmatter = new( title: titleValue?.ToString() ?? sourceFileNameWithoutExtension, site: site, + clock: dateTime, sourcePath: filePath, sourceFileNameWithoutExtension: sourceFileNameWithoutExtension, sourcePathDirectory: null @@ -121,6 +124,10 @@ public partial class YAMLParser : IFrontmatterParser _ = site.CreateAutomaticFrontmatter(contentTemplate, frontmatter); } } + else + { + throw new YamlDotNet.Core.YamlException("Frontmatter yaml parsing failed"); + } } if (frontmatter is not null) { @@ -131,7 +138,12 @@ public partial class YAMLParser : IFrontmatterParser return null; } - private static string GetSection(string filePath) + /// + /// Get the section name from a file path + /// + /// + /// + public static string GetSection(string filePath) { // Split the path into individual folders var folders = filePath?.Split(Path.DirectorySeparatorChar); @@ -154,8 +166,17 @@ public partial class YAMLParser : IFrontmatterParser /// Site or Frontmatter object, that implements IParams /// The type (Site or Frontmatter) /// YAML content - void ParseParams(IParams settings, Type type, string content) + public void ParseParams(IParams settings, Type type, string content) { + if (settings is null) + { + throw new ArgumentNullException(nameof(settings)); + } + if (type is null) + { + throw new ArgumentNullException(nameof(type)); + } + var yamlObject = yamlDeserializer.Deserialize(new StringReader(content)); if (yamlObject is Dictionary yamlDictionary) { diff --git a/source/Program.cs b/source/Program.cs index b42756ea751290548b898fad8710da839cd27577..a609aad189ab413b021e2f6a1728cda77e8a6143 100644 --- a/source/Program.cs +++ b/source/Program.cs @@ -11,13 +11,33 @@ namespace SuCoS; /// public class Program { - private static int Main(string[] args) + private ILogger logger; + + /// + /// Constructor + /// + public Program(ILogger logger) + { + this.logger = logger; + } + + /// + /// Entry point of the program + /// + /// + /// + public static int Main(string[] args) { - // use Serilog to log the program's output - Log.Logger = new LoggerConfiguration() + ILogger logger = new LoggerConfiguration() .WriteTo.Console(formatProvider: System.Globalization.CultureInfo.CurrentCulture) .CreateLogger(); + var program = new Program(logger); + return program.Run(args); + } + + private int Run(string[] args) + { // Print the logo of the program. OutputLogo(); @@ -26,7 +46,7 @@ public class Program var assemblyName = assembly?.GetName(); var appName = assemblyName?.Name; var appVersion = assemblyName?.Version; - Log.Information("{name} v{version}", appName, appVersion); + logger.Information("{name} v{version}", appName, appVersion); // Shared options between the commands var sourceOption = new Option(new[] { "--source", "-s" }, () => ".", "Source directory path"); @@ -51,11 +71,11 @@ public class Program Output = output, Future = future }; - Log.Logger = new LoggerConfiguration() + logger = new LoggerConfiguration() .MinimumLevel.Is(verbose ? LogEventLevel.Debug : LogEventLevel.Information) .WriteTo.Console(formatProvider: System.Globalization.CultureInfo.CurrentCulture) .CreateLogger(); - _ = new BuildCommand(buildOptions); + _ = new BuildCommand(buildOptions, logger); }, sourceOption, buildOutputOption, futureOption, verboseOption); @@ -73,12 +93,12 @@ public class Program Source = source, Future = future }; - Log.Logger = new LoggerConfiguration() + logger = new LoggerConfiguration() .MinimumLevel.Is(verbose ? LogEventLevel.Debug : LogEventLevel.Information) .WriteTo.Console(formatProvider: System.Globalization.CultureInfo.CurrentCulture) .CreateLogger(); - var serveCommand = new ServeCommand(serverOptions); + var serveCommand = new ServeCommand(serverOptions, logger); await serveCommand.RunServer(); await Task.Delay(-1); // Wait forever. }, @@ -93,9 +113,9 @@ public class Program return rootCommand.Invoke(args); } - private static void OutputLogo() + private void OutputLogo() { - Log.Information(@" + logger.Information(@" ____ ____ ____ /\ _`\ /\ _`\ /\ _`\ \ \,\L\_\ __ __\ \ \/\_\ ___\ \,\L\_\ diff --git a/source/ServeCommand.cs b/source/ServeCommand.cs index 368c18eb23daf0a134130f7cff591198e12704f6..f3043e72cef3db0400dcf586ed0d82cf9a96a903 100644 --- a/source/ServeCommand.cs +++ b/source/ServeCommand.cs @@ -9,6 +9,8 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.StaticFiles; using Microsoft.Extensions.DependencyInjection; using Serilog; +using SuCoS.Helper; +using SuCoS.Models; namespace SuCoS; @@ -89,7 +91,7 @@ public class ServeCommand : BaseGeneratorCommand, IDisposable /// A Task representing the asynchronous operation. public async Task StartServer(string baseURL, int port) { - Log.Information("Starting server..."); + logger.Information("Starting server..."); // Generate the build report stopwatch.LogReport(site.Title); @@ -108,7 +110,7 @@ public class ServeCommand : BaseGeneratorCommand, IDisposable .Build(); await host.StartAsync(); - Log.Information("You site is live: {baseURL}:{port}", baseURL, port); + logger.Information("You site is live: {baseURL}:{port}", baseURL, port); } @@ -125,7 +127,8 @@ public class ServeCommand : BaseGeneratorCommand, IDisposable /// Constructor for the ServeCommand class. /// /// ServeOptions object specifying the serve options. - public ServeCommand(ServeOptions options) : base(options) + /// The logger instance. Injectable for testing + public ServeCommand(ServeOptions options, ILogger logger) : base(options, logger) { this.options = options ?? throw new ArgumentNullException(nameof(options)); var baseURL = "http://localhost"; @@ -145,7 +148,7 @@ public class ServeCommand : BaseGeneratorCommand, IDisposable { var SourceAbsolutePath = Path.GetFullPath(SourcePath); - Log.Information("Watching for file changes in {SourceAbsolutePath}", SourceAbsolutePath); + logger.Information("Watching for file changes in {SourceAbsolutePath}", SourceAbsolutePath); var fileWatcher = new FileSystemWatcher { @@ -173,7 +176,7 @@ public class ServeCommand : BaseGeneratorCommand, IDisposable try { - site = SiteHelper.Init(configFile, options, frontmatterParser, WhereParamsFilter, stopwatch); + site = SiteHelper.Init(configFile, options, frontmatterParser, WhereParamsFilter, logger, stopwatch); // Stop the server if (host != null) @@ -203,7 +206,7 @@ public class ServeCommand : BaseGeneratorCommand, IDisposable var fileAbsolutePath = Path.Combine(options.Source, "static", requestPath.TrimStart('/')); - Log.Debug("Request received for {RequestPath}", requestPath); + logger.Debug("Request received for {RequestPath}", requestPath); // Return the server startup timestamp as the response if (requestPath == "/ping") @@ -277,7 +280,7 @@ public class ServeCommand : BaseGeneratorCommand, IDisposable /// /// The content to inject the reload script into. /// The content with the reload script injected. - private static string InjectReloadScript(string content) + private string InjectReloadScript(string content) { // Read the content of the JavaScript file string scriptContent; @@ -291,8 +294,8 @@ public class ServeCommand : BaseGeneratorCommand, IDisposable } catch (Exception ex) { - Log.Error(ex, "Could not read the JavaScript file."); - throw ex; + logger.Error(ex, "Could not read the JavaScript file."); + throw; } // Inject the JavaScript content @@ -318,7 +321,7 @@ public class ServeCommand : BaseGeneratorCommand, IDisposable { if (!restartInProgress) { - Log.Information("File change detected: {FullPath}", e.FullPath); + logger.Information("File change detected: {FullPath}", e.FullPath); restartInProgress = true; await RestartServer(); diff --git a/test/.TestSites/01/content/test01.md b/test/.TestSites/01/content/test01.md new file mode 100644 index 0000000000000000000000000000000000000000..2d3c4158bf543cd81271fdb19c46fd6442f43d12 --- /dev/null +++ b/test/.TestSites/01/content/test01.md @@ -0,0 +1,5 @@ +--- +Title: Test Content 1 +--- + +Test Content 1 diff --git a/test/.TestSites/01/content/test02.md b/test/.TestSites/01/content/test02.md new file mode 100644 index 0000000000000000000000000000000000000000..7dfb40d58b564b8177d0abbb482489925a4b0f52 --- /dev/null +++ b/test/.TestSites/01/content/test02.md @@ -0,0 +1,5 @@ +--- +Title: Test Content 2 +--- + +Test Content 2 diff --git a/test/BaseGeneratorCommandTests.cs b/test/BaseGeneratorCommandTests.cs new file mode 100644 index 0000000000000000000000000000000000000000..e65e4192a254f51752a6ac4ef1b0445992d824fe --- /dev/null +++ b/test/BaseGeneratorCommandTests.cs @@ -0,0 +1,63 @@ +using System.Reflection; +using Fluid; +using Fluid.Values; +using Serilog; +using Xunit; + +namespace SuCoS.Tests; + +public class BaseGeneratorCommandTests +{ + private static readonly IGenerateOptions testOptions = new BuildOptions + { + Source = "test_source" + }; + + private static readonly ILogger testLogger = new LoggerConfiguration().CreateLogger(); + + private class BaseGeneratorCommandStub : BaseGeneratorCommand + { + public BaseGeneratorCommandStub(IGenerateOptions options, ILogger logger) + : base(options, logger) { } + } + + [Fact] + public void Constructor_ShouldThrowArgumentNullException_WhenOptionsIsNull() + { + Assert.Throws(() => new BaseGeneratorCommandStub(null!, testLogger)); + } + + [Fact] + public void Constructor_ShouldThrowArgumentNullException_WhenLoggerIsNull() + { + Assert.Throws(() => new BaseGeneratorCommandStub(testOptions, null!)); + } + + [Fact] + public async Task WhereParamsFilter_ShouldThrowArgumentNullException_WhenInputIsNull() + { + await Assert.ThrowsAsync( + () => BaseGeneratorCommand.WhereParamsFilter(null!, new FilterArguments(), new TemplateContext()).AsTask()); + } + + [Fact] + public async Task WhereParamsFilter_ShouldThrowArgumentNullException_WhenArgumentsIsNull() + { + await Assert.ThrowsAsync( + () => BaseGeneratorCommand.WhereParamsFilter(new ArrayValue(new FluidValue[0]), null!, new TemplateContext()).AsTask()); + } + + [Fact] + public void CheckValueInDictionary_ShouldWorkCorrectly() + { + var type = typeof(BaseGeneratorCommand); + var method = type.GetMethod("CheckValueInDictionary", BindingFlags.NonPublic | BindingFlags.Static); + var parameters = new object[] { new[] { "key" }, new Dictionary { { "key", "value" } }, "value" }; + + Assert.NotNull(method); + var result = method.Invoke(null, parameters); + + Assert.NotNull(result); + Assert.True((bool)result!); + } +} diff --git a/test/Helpers/UrlizerTests.cs b/test/Helpers/UrlizerTests.cs new file mode 100644 index 0000000000000000000000000000000000000000..09a1c9d31cd873c3f7242eb661c32b6eb30b2b71 --- /dev/null +++ b/test/Helpers/UrlizerTests.cs @@ -0,0 +1,91 @@ +using Xunit; +using SuCoS.Helper; + +namespace SuCoSTests; + +public class UrlizerTests +{ + [Theory] + [InlineData(null)] + [InlineData("")] + public void Urlize_NullOrEmptyText_ThrowsArgumentNullException(string text) + { + var result = Urlizer.Urlize(text); + Assert.Equal("", result); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void UrlizePath_NullPath_ReturnsEmptyString(string path) + { + var result = Urlizer.UrlizePath(path); + + Assert.Equal("", result); + } + + [Theory] + [InlineData("Hello, World!", '-', true, false, "hello-world")] + [InlineData("Hello, World!", '_', true, false, "hello_world")] + [InlineData("Hello, World!", '-', false, false, "Hello-World")] + [InlineData("Hello.World", '-', true, false, "hello.world")] + [InlineData("Hello.World", '-', true, true, "hello-world")] + public void Urlize_ValidText_ReturnsExpectedResult(string text, char? replacementChar, bool lowerCase, bool replaceDot, string expectedResult) + { + var options = new UrlizerOptions { ReplacementChar = replacementChar, LowerCase = lowerCase, ReplaceDot = replaceDot }; + var result = Urlizer.Urlize(text, options); + + Assert.Equal(expectedResult, result); + } + + [Theory] + [InlineData("Documents/My Report.docx", '-', true, false, "documents/my-report.docx")] + [InlineData("Documents/My Report.docx", '_', true, false, "documents/my_report.docx")] + [InlineData("Documents/My Report.docx", '-', false, false, "Documents/My-Report.docx")] + [InlineData("Documents/My Report.docx", '-', true, true, "documents/my-report-docx")] + [InlineData("C:/Documents/My Report.docx", '_', true, true, "c/documents/my_report_docx")] + [InlineData("Documents/My Report.docx", null, true, false, "documents/myreport.docx")] + public void UrlizePath_ValidPath_ReturnsExpectedResult(string path, char? replacementChar, bool lowerCase, bool replaceDot, string expectedResult) + { + var options = new UrlizerOptions { ReplacementChar = replacementChar, LowerCase = lowerCase, ReplaceDot = replaceDot }; + var result = Urlizer.UrlizePath(path, options); + + Assert.Equal(expectedResult, result); + } + + [Fact] + public void Urlize_WithoutOptions_ReturnsExpectedResult() + { + var text = "Hello, World!"; + var result = Urlizer.Urlize(text); + + Assert.Equal("hello-world", result); + } + + [Fact] + public void UrlizePath_WithoutOptions_ReturnsExpectedResult() + { + var path = "Documents/My Report.docx"; + var result = Urlizer.UrlizePath(path); + + Assert.Equal("documents/my-report.docx", result); + } + + [Fact] + public void Urlize_SpecialCharsInText_ReturnsOnlyHyphens() + { + var text = "!@#$%^&*()"; + var result = Urlizer.Urlize(text); + + Assert.Equal("", result); + } + + [Fact] + public void UrlizePath_SpecialCharsInPath_ReturnsOnlyHyphens() + { + var path = "/!@#$%^&*()/"; + var result = Urlizer.UrlizePath(path); + + Assert.Equal("/", result); + } +} diff --git a/test/Models/BasicContentTests.cs b/test/Models/BasicContentTests.cs new file mode 100644 index 0000000000000000000000000000000000000000..a492b600f88623525e564b45c4f4b4a588dd686b --- /dev/null +++ b/test/Models/BasicContentTests.cs @@ -0,0 +1,38 @@ +namespace SuCoS.Models.Tests; + +using Xunit; +using SuCoS.Models; + +public class BasicContentTests +{ + [Theory] + [InlineData("Title1", "Section1", "Type1", "URL1", Kind.single)] + [InlineData("Title2", "Section2", "Type2", "URL2", Kind.list)] + [InlineData("Title3", "Section3", "Type3", "URL3", Kind.index)] + public void Constructor_Sets_Properties_Correctly(string title, string section, string type, string url, Kind kind) + { + // Act + var basicContent = new BasicContent(title, section, type, url, kind); + + // Assert + Assert.Equal(title, basicContent.Title); + Assert.Equal(section, basicContent.Section); + Assert.Equal(type, basicContent.Type); + Assert.Equal(url, basicContent.URL); + Assert.Equal(kind, basicContent.Kind); + } + + [Fact] + public void Constructor_Sets_Kind_To_List_If_Not_Provided() + { + // Arrange + string title = "Title1", section = "Section1", type = "Type1", url = "URL1"; + var kind = Kind.list; + + // Act + var basicContent = new BasicContent(title, section, type, url); + + // Assert + Assert.Equal(kind, basicContent.Kind); + } +} diff --git a/test/Models/FrontmatterTests.cs b/test/Models/FrontmatterTests.cs new file mode 100644 index 0000000000000000000000000000000000000000..89f6f8d30232acd8cf4e1dbf36466f360608ba85 --- /dev/null +++ b/test/Models/FrontmatterTests.cs @@ -0,0 +1,172 @@ +using System.Globalization; +using Moq; +using SuCoS.Models; +using Xunit; + +namespace SuCoS.Tests; + +public class FrontmatterTests +{ + private readonly ISystemClock clock; + private readonly Mock systemClockMock; + private readonly string title = "Test Title"; + private readonly string sourcePath = "/path/to/file.md"; + private readonly Site site; + + public FrontmatterTests() + { + systemClockMock = new Mock(); + var testDate = DateTime.Parse("2023-04-01", CultureInfo.InvariantCulture); + systemClockMock.Setup(c => c.Now).Returns(testDate); + + clock = systemClockMock.Object; + site = new(clock); + } + + [Theory] + [InlineData("Test Title", "/path/to/file.md", "file", "/path/to")] + public void ShouldCreateFrontmatterWithCorrectProperties(string title, string sourcePath, string sourceFileNameWithoutExtension, string sourcePathDirectory) + { + var frontmatter = new Frontmatter(title, sourcePath, site, clock, sourceFileNameWithoutExtension, sourcePathDirectory); + + Assert.Equal(title, frontmatter.Title); + Assert.Equal(sourcePath, frontmatter.SourcePath); + Assert.Same(site, frontmatter.Site); + Assert.Equal(sourceFileNameWithoutExtension, frontmatter.SourceFileNameWithoutExtension); + Assert.Equal(sourcePathDirectory, frontmatter.SourcePathDirectory); + } + + [Fact] + public void ShouldHaveDefaultValuesForOptionalProperties() + { + // Arrange + var frontmatter = new Frontmatter("Test Title", "/path/to/file.md", site, clock); + + // Assert + Assert.Equal(string.Empty, frontmatter.Section); + Assert.Equal(Kind.single, frontmatter.Kind); + Assert.Equal(string.Empty, frontmatter.Type); + Assert.Null(frontmatter.URL); + Assert.Empty(frontmatter.Params); + Assert.Null(frontmatter.Date); + Assert.Null(frontmatter.LastMod); + Assert.Null(frontmatter.PublishDate); + Assert.Null(frontmatter.ExpiryDate); + Assert.Null(frontmatter.Aliases); + Assert.Null(frontmatter.Permalink); + Assert.Empty(frontmatter.Urls); + Assert.Equal(string.Empty, frontmatter.RawContent); + Assert.Null(frontmatter.Tags); + Assert.Null(frontmatter.PagesReferences); + Assert.Empty(frontmatter.RegularPages); + Assert.Equal(string.Empty, frontmatter.Language); + Assert.False(frontmatter.IsDateExpired); + Assert.True(frontmatter.IsDatePublishable); + } + + [Fact] + public void ShouldReturnValidDateBasedOnExpiryDateAndPublishDate() + { + var publishDate = new DateTime(2023, 6, 1); + var expiryDate = new DateTime(2023, 6, 3); + + systemClockMock.Setup(c => c.Now).Returns(new DateTime(2023, 6, 2)); + + var frontmatter = new Frontmatter(title, sourcePath, site, clock) + { + ExpiryDate = expiryDate, + PublishDate = publishDate + }; + + Assert.True(frontmatter.IsValidDate(null)); + } + + [Theory] + [InlineData(null, "/path/to/test-title")] + [InlineData("{{ page.Title }}/{{ page.SourceFileNameWithoutExtension }}", "/test-title/file")] + public void ShouldCreatePermalinkWithDefaultOrCustomURLTemplate(string urlTemplate, string expectedPermalink) + { + var frontmatter = new Frontmatter(title, sourcePath, site, clock) + { + URL = urlTemplate + }; + + var actualPermalink = frontmatter.CreatePermalink(); + + Assert.Equal(expectedPermalink, actualPermalink); + } + + [Theory] + [InlineData(-1, true)] + [InlineData(1, false)] + public void IsDateExpired_ShouldReturnExpectedResult(int days, bool expected) + { + systemClockMock.Setup(c => c.Now).Returns(new DateTime(2023, 6, 28)); + + var frontmatter = new Frontmatter(title, sourcePath, site, clock) + { + ExpiryDate = clock.Now.AddDays(days) + }; + + Assert.Equal(expected, frontmatter.IsDateExpired); + } + + [Theory] + [InlineData(false, false)] + [InlineData(true, true)] + public void IsValidDate_ShouldReturnExpectedResult(bool futureOption, bool expected) + { + systemClockMock.Setup(c => c.Now).Returns(new DateTime(2023, 6, 28)); + + var frontmatter = new Frontmatter(title, sourcePath, site, clock) + { + Date = clock.Now.AddDays(1) + }; + + var options = new Mock(); + options.Setup(o => o.Future).Returns(futureOption); + + Assert.Equal(expected, frontmatter.IsValidDate(options.Object)); + } + + [Theory] + [InlineData("/test/path", "/test/path/test-title")] + [InlineData("/another/path", "/another/path/test-title")] + public void CreatePermalink_ShouldReturnCorrectUrl_WhenUrlIsNull(string sourcePathDirectory, string expectedUrl) + { + var frontmatter = new Frontmatter(title, sourcePath, site, clock) + { + SourcePathDirectory = sourcePathDirectory + }; + + Assert.Equal(expectedUrl, frontmatter.CreatePermalink()); + } + + [Theory] + [InlineData(Kind.single, true)] + [InlineData(Kind.list, false)] + public void RegularPages_ShouldReturnCorrectPages_WhenKindIsSingle(Kind kind, bool isExpectedPage) + { + var page = new Frontmatter(title, sourcePath, site, clock) { Kind = kind }; + site.PostProcessFrontMatter(page); + + Assert.Equal(isExpectedPage, site.RegularPages.Contains(page)); + } + + [Theory] + [InlineData(null, null, true)] + [InlineData(null, "2024-06-28", false)] + [InlineData("2022-06-28", null, true)] + [InlineData("2024-06-28", "2022-06-28", false)] + [InlineData("2022-06-28", "2024-06-28", true)] + public void IsDatePublishable_ShouldReturnCorrectValues(string? publishDate, string? date, bool expectedValue) + { + var frontmatter = new Frontmatter(title, sourcePath, site, clock) + { + PublishDate = publishDate is null ? null : DateTime.Parse(publishDate, CultureInfo.InvariantCulture), + Date = date is null ? null : DateTime.Parse(date, CultureInfo.InvariantCulture) + }; + + Assert.Equal(expectedValue, frontmatter.IsDatePublishable); + } +} diff --git a/test/Models/SiteTests.cs b/test/Models/SiteTests.cs new file mode 100644 index 0000000000000000000000000000000000000000..9d4ba9c266785bfa5ac735bd179dec23f63faa87 --- /dev/null +++ b/test/Models/SiteTests.cs @@ -0,0 +1,62 @@ +using Xunit; +using Moq; +using SuCoS.Models; +using System.Globalization; + +namespace SuCoS.Tests; + +/// +/// Unit tests for the Site class. +/// +public class SiteTests +{ + private readonly Site site; + private readonly Mock systemClockMock; + readonly string testSite1Path = ".TestSites/01"; + + public SiteTests() + { + systemClockMock = new Mock(); + var testDate = DateTime.Parse("2023-04-01", CultureInfo.InvariantCulture); + systemClockMock.Setup(c => c.Now).Returns(testDate); + site = new Site(systemClockMock.Object); + } + + [Theory] + [InlineData("test01.md", @"--- +Title: Test Content 1 +--- + +Test Content 1 +")] + [InlineData("test02.md", @"--- +Title: Test Content 2 +--- + +Test Content 2 +")] + public void Test_ScanAllMarkdownFiles(string fileName, string fileContent) + { + site.SourceDirectoryPath = testSite1Path; + site.ScanAllMarkdownFiles(); + + Assert.Contains(site.RawPages, rp => rp.filePath == fileName && rp.content == fileContent); + } + + [Theory] + [InlineData("test1", Kind.index, "base", "Test Content 1")] + [InlineData("test2", Kind.single, "content", "Test Content 2")] + public void Test_ResetCache(string firstKeyPart, Kind secondKeyPart, string thirdKeyPart, string value) + { + var key = (firstKeyPart, secondKeyPart, thirdKeyPart); + site.baseTemplateCache.Add(key, value); + site.contentTemplateCache.Add(key, value); + site.PagesDict.Add("test", new Frontmatter("Test Title", "sourcePath", site, systemClockMock.Object)); + + site.ResetCache(); + + Assert.Empty(site.baseTemplateCache); + Assert.Empty(site.contentTemplateCache); + Assert.Empty(site.PagesDict); + } +} diff --git a/test/Parser/YAMLParserTests.cs b/test/Parser/YAMLParserTests.cs new file mode 100644 index 0000000000000000000000000000000000000000..a2e3551ea45f9f964b712fa2e9cc8334aaee92f1 --- /dev/null +++ b/test/Parser/YAMLParserTests.cs @@ -0,0 +1,155 @@ +using Xunit; +using Moq; +using SuCoS.Models; +using SuCoS.Parser; +using System.Globalization; + +namespace SuCoS.Tests; + +public class YAMLParserTests +{ + private readonly YAMLParser parser; + private readonly Mock mockSite; + + public YAMLParserTests() + { + parser = new YAMLParser(); + mockSite = new Mock(); + } + + [Theory] + [InlineData(@"--- +Title: Test Title +--- +", "Test Title")] + [InlineData(@"--- +--- +", null)] + public void ParseFrontmatter_ShouldParseTitleCorrectly(string fileContent, string expectedTitle) + { + var filePath = "test.md"; + var frontmatter = parser.ParseFrontmatter(mockSite.Object, filePath, ref fileContent); + + Assert.Equal(expectedTitle, frontmatter?.Title); + } + + [Fact] + public void ParseFrontmatter_ShouldThrowException_WhenSiteIsNull() + { + var fileContent = @"--- +Title: Test Title +--- +"; + + Assert.Throws(() => parser.ParseFrontmatter(null!, "test.md", ref fileContent)); + } + + [Fact] + public void GetSection_ShouldReturnFirstFolderName() + { + var filePath = Path.Combine("folder1", "folder2", "file.md"); + + var section = YAMLParser.GetSection(filePath); + + Assert.Equal("folder1", section); + } + + [Theory] + [InlineData(@"--- +Date: 2023-01-01 +--- +", "2023-01-01")] + [InlineData(@"--- +Date: 2023/01/01 +--- +", "2023-01-01")] + public void ParseFrontmatter_ShouldParseDateCorrectly(string fileContent, string expectedDateString) + { + var filePath = "test.md"; + var expectedDate = DateTime.Parse(expectedDateString, CultureInfo.InvariantCulture); + + var frontmatter = parser.ParseFrontmatter(mockSite.Object, filePath, ref fileContent); + + Assert.Equal(expectedDate, frontmatter?.Date); + } + + [Fact] + public void ParseFrontmatter_ShouldParseOtherFieldsCorrectly() + { + var filePath = "test.md"; + var fileContent = @"--- +Title: Test Title +Type: post +Date: 2023-01-01 +LastMod: 2023-06-01 +PublishDate: 2023-06-01 +ExpiryDate: 2024-06-01 +--- +"; + var expectedDate = DateTime.Parse("2023-01-01", CultureInfo.InvariantCulture); + var expectedLastMod = DateTime.Parse("2023-06-01", CultureInfo.InvariantCulture); + var expectedPublishDate = DateTime.Parse("2023-06-01", CultureInfo.InvariantCulture); + var expectedExpiryDate = DateTime.Parse("2024-06-01", CultureInfo.InvariantCulture); + + var frontmatter = parser.ParseFrontmatter(mockSite.Object, filePath, ref fileContent); + + Assert.Equal("Test Title", frontmatter?.Title); + Assert.Equal("post", frontmatter?.Type); + Assert.Equal(expectedDate, frontmatter?.Date); + Assert.Equal(expectedLastMod, frontmatter?.LastMod); + Assert.Equal(expectedPublishDate, frontmatter?.PublishDate); + Assert.Equal(expectedExpiryDate, frontmatter?.ExpiryDate); + } + + [Fact] + public void ParseFrontmatter_ShouldThrowException_WhenInvalidYAMLSyntax() + { + var fileContent = @"--- +Title +--- +"; + var filePath = "test.md"; + + Assert.Throws(() => parser.ParseFrontmatter(mockSite.Object, filePath, ref fileContent)); + } + + [Fact] + public void ParseSiteSettings_ShouldReturnSiteWithCorrectSettings() + { + var siteContent = @" +BaseUrl: https://www.example.com/ +Title: My Site +"; + + var siteSettings = parser.ParseSiteSettings(siteContent); + + Assert.Equal("https://www.example.com/", siteSettings.BaseUrl); + Assert.Equal("My Site", siteSettings.Title); + } + + [Fact] + public void ParseParams_ShouldFillParamsWithNonMatchingFields() + { + var settings = new Frontmatter("Test Title", "/test.md", mockSite.Object, new SystemClock()); + var content = @" +Title: Test Title +customParam: Custom Value +"; + + parser.ParseParams(settings, typeof(Frontmatter), content); + + Assert.True(settings.Params.ContainsKey("customParam")); + Assert.Equal("Custom Value", settings.Params["customParam"]); + } + + [Fact] + public void ParseFrontmatter_ShouldReturnNull_WhenNoFrontmatter() + { + var fileContent = "There is no frontmatter in this content."; + var filePath = "test.md"; + + var frontmatter = parser.ParseFrontmatter(mockSite.Object, filePath, ref fileContent); + + Assert.Null(frontmatter); + } +} diff --git a/test/test.csproj b/test/test.csproj new file mode 100644 index 0000000000000000000000000000000000000000..520ac34b680d19ba7190debd0094713a0b1f89e6 --- /dev/null +++ b/test/test.csproj @@ -0,0 +1,26 @@ + + + + net7.0 + enable + enable + + false + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + +