2
\$\begingroup\$

How It Works:
Inspired by r/dailyprogrammer, the application uses regular expressions to group 1-3 digits at a time from right to left, each captured group is then converted to text.

Program Class

using System;

namespace CheckWriter
{
    internal class Program
    {
        private static readonly decimal[] testNumbers =
        {
            333.88m,
            742388.15m,
            919616.12m,
            12.11m,
            2.0m
        };

        private static void Main(string[] args)
        {
            var writer = new CheckWriter();
            foreach (var number in testNumbers)
            {
                Console.WriteLine(writer.BuildNumberTextOf(number));
            }
        }
    }
}

CheckWriter Class

using System;
using System.Text;
using System.Text.RegularExpressions;

namespace CheckWriter
{
    public class CheckWriter
    {
        // TODO: Can be modified to accept generated
        // pattern and groups for even larger numbers.
        private static readonly string[] groups =
        {
            "trillion",
            "billion",
            "million",
            "thousand",
            "hundred",
            "cents"
        };
        private static readonly string pattern =
            @"^(?<trillion>\d{1,3})?(?<billion>\d{1,3})?(?<million>\d{1,3})?(?<thousand>\d{1,3})?(?<hundred>\d{1,3})(\.(?<cents>\d{1,2}))?$";
        private static readonly RegexOptions options = RegexOptions.RightToLeft;
        private Regex regex;

        public CheckWriter()
        {
            regex = new Regex(pattern, options);
        }

        public string BuildNumberTextOf(decimal value)
        {
            var match = regex.Match(Convert.ToString(value));

            var sb = new StringBuilder();
            foreach (var group in groups)
            {
                var capturedValue = match.Groups[group].Value.TrimStart(new char[] { '0' });

                // Dollars wont be appended to result if the 
                // hundred group contains all zeros.
                if (capturedValue == "" && group == "hundred")
                {
                    sb.Append("dollars ");
                    continue;
                }
                else if (capturedValue == "")
                {
                    continue;
                }
                else
                {
                    var words = HundredsToWords(Convert.ToInt32(capturedValue));

                    // Make sure "hundred" doesnt double up since
                    // "hundred" is also appended in the HundredsToWords
                    // function.
                    if (group != "hundred")
                    {
                        var cents = group == "cents" ? "and " : "";
                        sb.Append($"{cents}{words} {group} ");
                    }
                    else
                    {
                        sb.Append($"{words} dollars ");
                    }
                }
            }

            return sb.ToString().TrimEnd();
        }

        private string HundredsToWords(int number)
        {
            if (number > 999) throw new ArgumentOutOfRangeException("Passed value too large.");

            var sb = new StringBuilder();

            if ((number / 100) > 0)
            {
                sb.Append(HundredsToWords(number / 100) + " hundred ");
                number %= 100;
            }

            if (number > 0)
            {
                var units = new[] { "zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten", "eleven", "twelve", "thirteen", "fourteen", "fifteen", "sixteen", "seventeen", "eighteen", "nineteen" };
                var tens = new[] { "zero", "ten", "twenty", "thirty", "forty", "fifty", "sixty", "seventy", "eighty", "ninety" };

                if (number < 20)
                {
                    sb.Append(units[number]);
                }
                else
                {
                    sb.Append(tens[number / 10]);
                    if ((number % 10) > 0)
                    {
                        sb.Append("-" + units[number % 10]);
                    }
                }
            }

            return sb.ToString();
        }
    }
}

Specific Question:
This implementation gets the job done for check amounts, but I didn't want to limit the class to smaller numbers. This design seems to allow for extremely large numbers to be converted to text as long as you can generate the required regex and use a BigNumber datatype (which this implementation clearly doesn't utilize.) Is this good design for the problem of converting extremely large numbers to text? I haven't seen an implementation like this for converting numbers to text, maybe for good reason?

Advice:
Any feedback or suggestions about any parts of the code is appreciated.

\$\endgroup\$
1
\$\begingroup\$

Functionality
In the original problem, 'and zero cents' was added to any whole numbers and the groups were separated by commas e.g.

Four hundred thousand, one hundred twenty dollars and zero cents.

the current code omits these giving something like (see below)

four hundred thousand one hundred twenty dollars

We shall assume that this is intentional and we are trying to implement a subset of the original problem.

Error
1) Appending the space to the end of 'hundred' in

var hundredCount = (number / 100);
if (hundredCount > 0)
{
    sb.Append(HundredsToWords(hundredCount) + " hundred ");
    number %= 100;
}

means that we get the wrong answer for

400120m => "four hundred  thousand one hundred twenty dollars"
100m => "one hundred  dollars"
500m => "five hundred  dollars"

all have extra spaces after the 'solo' hundred. Removing it breaks things in a different way. We now get

400120m => "four hundred thousand one hundredtwenty dollars"

the solution is to add the space further down

if (number > 0)
{
    if (number < 20)
    {
        sb.Append(units[number]);
    }
    else
    {
        sb.Append(" "); /* HERE */
        var tenCount = number / 10;
        var unitCount = number % 10;
        sb.Append(tens[tenCount]);
        if (unitCount > 0)
        {
             sb.Append("-" + units[unitCount]);
        }
    }
}

2) Incorrect formatting of string

An input of 100.1m gives us One hundred dollars and one cent, because of two pieces

  • We convert the number to a string using System.Convert.ToString() which gives us 100.1
  • We have a regex which matches against 1 or 2 digits for the cents group.

If we change the conversion to

var valueStr = value.ToString("#.00", CultureInfo.InvariantCulture);
var match = regex.Match(valueStr);

we fix the problem. (We should also change the regex to \d{2})

Note: We need the InvariantCulture because the Regex requires a . for the decimal separator. The current code would not work on, say, a French language machine.

Approach
The approach of converting the value to a string, using a regex to break up the string and then converting it to another string seems convoluted. The same result can be obtained by a similar approach to the one used in HundredsToWords()

private IList<Order> _orders = new List<Order> {
    new Order(1000000000, "billion"),
    new Order(1000000, "million"),
    new Order(1000, "thousand"),
    new Order(1, string.Empty)
};

public string Convert(decimal value)
{
    var current = value;
    var sb = new StringBuilder();
    foreach(var order in _orders)
    {
        var div = (int)(current / order.Range);
        if (div > 0)
        {
            sb.Append($"{HundredsToWords(value)}{name} ");
            current = current - (div * order.Range);
            if (current < 1) break;
        }
    }

    var ret = $"{sb.ToString().Trim()} dollars";

    if(current > 0)
    {
        ret = $"{ret} and {HundredsToWords((int)(current*100))} cents";
    }

    return ret;

}

#region Order

private class Order
{
    public Order(long range, string name)
    {
        Range = range;
        Name = (name.Length> 0 ? " " : string.Empty) + name;
    }
    public long Range { get; } 
    public string Name { get; }
}

#endregion

Ok, there is some messing around with spaces, and with a bit more thought I could probably clean it up more but the basic shape should hold.

Testing
A personal hobby horse. I would really push for unit tests with any of the many frameworks out there as a way to test code rather than Console.WriteLine() statements. This was a prefect candidate for automated testing. I was able to set up tests and know immediately if they passed/failed and run the same tests against both implementations to see any differences. Appropriately selected tests (there were no tests for cent values between 1 and 9, so the formatting problem was not noticed) with written results (extra or missing spaces, for example, can be easily missed when sight checking results) speed up development and changes to code immensely.

[TestClass]
public class ConverterFixture
{

    #region Setup


    private void Execute(decimal value, string expected, Func<Converter, decimal, string> converter)
    {
        var actual = converter(new Converter(),value);
        Assert.AreEqual(expected, actual);
    }

    #endregion


    private void CheckValues(Func<Converter, decimal, string> converter)
    {
        Execute(1.1m, "one dollars and ten cents", converter);
        Execute(1.01m, "one dollars and one cents", converter);
        Execute(400120m, "four hundred thousand one hundred twenty dollars", converter);
        Execute(100m, "one hundred dollars", converter);
        Execute(150m, "one hundred fifty dollars", converter);
        Execute(500m, "five hundred dollars", converter);
        Execute(1000m, "one thousand dollars", converter);
        Execute(10000m, "ten thousand dollars", converter);
        Execute(100000m, "one hundred thousand dollars", converter);
        Execute(1000000m, "one million dollars", converter);
        Execute(100000000m, "one hundred million dollars", converter);
        Execute(1000000000m, "one billion dollars", converter);
        Execute(100000000000m, "one hundred billion dollars", converter);

    }

    [TestMethod]
    public void CheckConvertOld()
    {
        CheckValues((cnv, v) => cnv.BuildNumberTextOf(v));
    }

    [TestMethod]
    public void CheckConvertNew()
    {
        CheckValues((cnv, v) => cnv.Convert(v));
    }

}
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.