Extension methods and testing to solve a problem

Got a problem? Code a fix for it!

On my other blog I talk about my running and paces that I run on a regular basis. One problem I had was I tend to run in kilometers pace, rather than miles pace. I talk about 4:15min/k rather than 6:50min/mi, for example. But I wanted people who run in miles to be able to understand the paces that I run. 

The solution in the past was to use a pace convertor tool, type in my km pace then find the converted value. This was time consuming, especially if I was talking about running 10 reps at varying paces. 

Initial thoughts.

I thought I would create a notification handler in Umbraco, I would then iterate over all the Grid Blocks that I have on a page, look for a token that had the pace in it e.g. 4:15min/k (6:50min/mi) and then do some maths, replace the value and output it was "4:15min/k (6:50min/mi)". After a fair bit of trying different ways I just could not get it to work. The biggest issue I had was the IEnumerable<BlockList> was coming back as IContent, not IPublishedContent to actually trying to find the blocks and their contents was problematic. 

I spoke to some good dev friends and between us the conversation went from using Notification Handlers to doing this conversion at render time, using an Extension method. 

After more chatting with them, using ChatGPT and Copilot, I eventually got some code that did what I needed it to do. 

Code

In my Razor View I have @Html.Raw(Model.Content.TextBlock.ConvertPace()) this reads the content value of the BlockListItem called TextBlock and then passes the data to my extension method called ConvertPace

Obvioulsy this does mean that everything is passed to this extension method, even if I write a blog that doesn't have a pace in it but more about that in a second.

My extension method takes the IHTMLEncodedString value as the input. Note, IHTMLEncodedString is an Umbraco thing, not a standard .Net thing.

Here is my extension method in full, I'll explain some of the things I found interesting further down this blog :

public static IHtmlEncodedString ConvertPace(this IHtmlEncodedString input)
{

    // Extract the pace value from the token (assuming it's in the format 4:15min/k (6:50min/mi))
    string pattern = @"\{\{(\d+):(\d+)\}\}"; // Updated pattern
    Match match = Regex.Match(input.ToString(), pattern);

    if (match.Success)
    {
        string minutesPart = match.Groups[1].Value; // Minutes
        string secondsPart = match.Groups[2].Value; // Seconds

        // Parse minutes and seconds
        int minutes = int.Parse(minutesPart);
        int seconds = int.Parse(secondsPart);

        // Convert minutes and seconds to total seconds
        double totalSeconds = minutes * 60 + seconds;

        // Convert minutes per kilometer to minutes per mile
        double paceInMinutesPerMile = (totalSeconds * 1.609344) / 60;


        // Format the output with preserved Markdown
        string convertedPace = $"{minutes}:{seconds:00}min/k ({TimeSpan.FromMinutes(paceInMinutesPerMile):m\\:ss}min/mi)";

        // Replace the original token with the converted pace
        string output = input.ToString().Replace(match.Value, convertedPace);


        // Create a new IHtmlString with the converted pace
        return new HtmlEncodedString(output);
    }
    else
    {
        // No valid pace token found, return the original input
        return input;
    }
}

Regex to the rescue

So that I'm not wasting any time when using this extension method, I have a regex \{\{(\d+):(\d+)\}\}. The \ are used for escaping the curly braces as they have special meaning in a regular expression, so we first look for {{ and then use (\d+) to capture one or more digits. The \d represents any digit (0-9) and the + means it should occur one or more times. I then check that there is a colon between the two digits, so 4:50 would be a match but 4.50 wouldn't,

If there is a match then we carry on, otherwise we just return the input value unchanged.

Lets add some tests!

During my conversation with my friends, one suggested that I create some simple tests for this extension method. So I did because, well, testing your code is a good idea but also it meant I could test my code without loading up Umbraco every time. I could also test a couple of different paces quickly and easiliy.

I added a new NUnit project to my solution in Visual Studio and chucked some tests together.

 [TestFixture]
 public class PaceConverterTests
 {

     [Test]
     [TestCase("4:15min/k (6:50min/mi)", "4:15min/k (6:50min/mi)")]
     [TestCase("4:30min/k (7:14min/mi)", "4:30min/k (7:14min/mi)")]
     [TestCase("5:00min/k (8:02min/mi)", "5:00min/k (8:02min/mi)")]
     [TestCase("5:30min/k (8:51min/mi)", "5:30min/k (8:51min/mi)")]
     public void ConvertKilometerPaceToMilePace(string kmPace, string kmMiPace)
     {
         // Arrange
         string input = kmPace;
         string expectedOutput = kmMiPace;

         // Act
         IHtmlEncodedString output = new HtmlEncodedString(input).ConvertPace();
         Assert.That(output.ToString, Is.EqualTo(expectedOutput));

         Assert.Pass();
     }
 }

I ran these tests and to my surprise, I got a fail!

Expected 5:00min/k (8:02min/mi) but was 5:0min/k (8:02min/mi

That was the error I got. So some quick searching around the web, reading some documentation and then eventually asking Copilot, I got the answer.

Formatting the time

 // Format the output with preserved Markdown
        string convertedPace = $"{minutes}:{seconds:00}min/k ({TimeSpan.FromMinutes(paceInMinutesPerMile):m\\:ss}min/mi)";

I had to change

string convertedPace = $"{minutes}:{seconds}min/k 

to

string convertedPace = $"{minutes}:{seconds:00}min/k

By adding the :00 it ensures that a leading zero is added.

TODO: more improvements

  • Make the extension method handle multiple instances of a token
  • Add error handling
  • Look to if stringbuilder would be more memory efficient at replacing the string
  • Add more unit tests

Thanks to Nik, Matt and Paul for the conversation and suggestions on how to improve this.

Published on : 11 April 2024