Categories
Agile BT Ruby Software

A step-by-step BDD example using RSpec

We’ve now got a Ruby focus group at work, and one of the first things to be set up has been a weekly programming exercise [intranet link], in the style of Ruby Quiz. It’s now week two, and the problem is slightly more complex than last week’s gentle FizzBuzz introduction. Here’s the specification:

This time, the challenge is to come up with some Ruby code that converts a positive integer to its English language
equivalent. For example:

1 => one

10 => ten

123 => one hundred and twenty three

10,456 => ten thousand four hundred and fifty six

1,234,123 => one million two hundred thirty four thousand one hundred and twenty three

The code should work from numbers 1 – 10,000,000,000 (ten billion) but if it works for bigger numbers its all good.

For an extra challenge, when the strings for the numbers for 1 – 10,000,000,000 are sorted alphabetically, which is the
first odd number in the list?

I thought it might be interesting (to me, at least!) to record the process I go through to reach the solution, rather than just sharing the finished article. I’m using a behaviour-driven approach, although the process for writing a single method obviously doesn’t touch on a lot of the wider aspects of BDD.

So here it is, warts and all (I’m writing this as I go along, so I have no idea how long this post is going to get, or whether I’ll even arrive at a solution at all!)

First, let’s describe the very simplest bit of behaviour: if I feed in 1, the output should be ‘one’. The obvious approach is to add a to_words method to the Integer class. Obvious to those who have their Ruby heads on, that is – to those more used to languages like Java, it probably sounds like the ravings of a mentalist).

Let’s create a specification file called to_words_spec.rb (I’m using rspec):

describe "1.to_words" do
  it "should be 'one'" do
    1.to_words.should == 'one'
  end
end

What happens when we run it?:

$ spec to_words_spec.rb
F

1)
NoMethodError in '1.to_words should be 'one''
undefined method `to_words' for 1:Fixnum
./to_words_spec.rb:3:

Finished in 0.009689 seconds

1 example, 1 failure

No surprise there. I’ll create another file, to_words.rb, where I define the method (for now I’ll leave it empy):

Integer.class_eval do
  def to_words
  end
end

Require this at the top of to_words_spec.rb:

require 'to_words'

And run the spec again:

F

1)
'1.to_words should be 'one'' FAILED
expected "one", got nil (using ==)
./to_words_spec.rb:5:

Finished in 0.009579 seconds

1 example, 1 failure

OK, so the method’s there now, but we haven’t got a return value. The easiest thing to do to make this spec pass is to just hardcode the return value:

  def to_words
    'one'
  end
.

Finished in 0.010926 seconds

1 example, 0 failures

The fact that we’ve hardcoded it means we need another example to describe the required behaviour for different inputs:

describe "2.to_words" do
  it "should be 'two'" do
    2.to_words.should == 'two'
  end
end
.F

1)
'2.to_words should be 'two'' FAILED
expected "two", got "one" (using ==)
./to_words_spec.rb:11:

Finished in 0.019927 seconds

2 examples, 1 failure

Let’s make the code a bit more intelligent. I think I’ll use an array to hold the names of the digits, but for now I’ll only populate it with enough data to pass the spec.

  def to_words
    # we never use zero, but it keeps the indexes inline
    numbers = ['zero', 'one', 'two']
    numbers[self]
  end
..

Finished in 0.010498 seconds

2 examples, 0 failures

Now I could paste the same example in a few times and change the numbers, but that would be a bit ugly. This looks better, and I think it’s still obvious what’s going on:

# Single digits
{1=>'one', 2=>'two', 3=>'three', 4=>'four', 5=>'five', 6=>'six', 7=>'seven',
  8=>'eight', 9=>'nine'}.each do |number, word|
  describe "#{number}.to_words" do
    it "should be '#{word}'" do
      number.to_words.should == word
    end
  end
end
FF.F.FFFF

1)
'5.to_words should be 'five'' FAILED
expected "five", got nil (using ==)
./to_words_spec.rb:8:

…

7)
'4.to_words should be 'four'' FAILED
expected "four", got nil (using ==)
./to_words_spec.rb:8:

Finished in 0.021579 seconds

9 examples, 7 failures

If I add the missing numbers, it should pass.

  def to_words
    # we never use zero, but it keeps the indexes inline
    numbers = ['zero', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine']
    numbers[self]
  end

If you add the -f s (short for –format specdoc) option, it’s easy to see the examples that are being dynamically generated:

$ spec -f s to_words_spec.rb

5.to_words
- should be 'five'

6.to_words
- should be 'six'

1.to_words
- should be 'one'

7.to_words
- should be 'seven'

2.to_words
- should be 'two'

8.to_words
- should be 'eight'

3.to_words
- should be 'three'

9.to_words
- should be 'nine'

4.to_words
- should be 'four'

Finished in 0.020708 seconds

9 examples, 0 failures

Because I’m using a hash, the examples aren’t run in numerical order. It’s not ideal, but I don’t think it’s worth complicating the spec to fix it.

An interesting point from the previous failures though – what should it do if the number falls outside the range it can cope with? For the sake of argument, I’ll make something up. To start with, the only numbers it can handle are one to nine: I’ll change the example as I expand the range. I’ll put 10,000,000,001 in now anyway.

# Examples of unhandled numbers
[-123, -1, 0, 10, 10_000_000_001].each do |number|
  describe "#{number}.to_words" do
    it "should be '?'" do
      number.to_words.should == '?'
    end
  end
end
.........FFFFF

1)
'-123.to_words should be '?'' FAILED
expected "?", got nil (using ==)
./to_words_spec.rb:17:

2)
'-1.to_words should be '?'' FAILED
expected "?", got "nine" (using ==)
./to_words_spec.rb:17:

3)
'0.to_words should be '?'' FAILED
expected "?", got "zero" (using ==)
./to_words_spec.rb:17:

4)
'10.to_words should be '?'' FAILED
expected "?", got nil (using ==)
./to_words_spec.rb:17:

5)
RangeError in '10000000001.to_words should be '?''
bignum too big to convert into `long'
./to_words.rb:5:in `[]'
./to_words.rb:5:in `to_words'
./to_words_spec.rb:17:

Finished in 0.026465 seconds

14 examples, 5 failures

Hmm, a few unexpected things going on here. Let’s fix the easy ones first by just returning a question mark if we don’t find a value. Also, putting zero in that numbers array when none of the examples required it has come back to bite me. I’ll change that to nil.

  def to_words
    # we never use zero, but it keeps the indexes inline
    numbers = [nil, 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine']
    numbers[self] or '?'
  end
..........F..F

1)
'-1.to_words should be '?'' FAILED
expected "?", got "nine" (using ==)
./to_words_spec.rb:17:

2)
RangeError in '10000000001.to_words should be '?''
bignum too big to convert into `long'
./to_words.rb:4:in `[]'
./to_words.rb:4:in `to_words'
./to_words_spec.rb:17:

Finished in 0.024623 seconds

14 examples, 2 failures

Still a couple of problems. The first one is obviously caused by the fact that in Ruby a negative index on an array means ‘count back from the end’, so I’ll explicitly check for zero or negative numbers.

  def to_words
    return '?' if self <= 0
    numbers = [nil, 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine']
    numbers[self] or '?'
  end
.............F

1)
RangeError in '10000000001.to_words should be '?''
bignum too big to convert into `long'
./to_words.rb:5:in `[]'
./to_words.rb:5:in `to_words'
./to_words_spec.rb:17:

Finished in 0.024365 seconds

14 examples, 1 failure

You may have noticed I've been leaving that one until last. Now everything else works, it's time to bite the bullet and figure out exactly what's going on here. It obviously doesn't like doing numbers[10000000001], but why is it converting it to a long? I didn't even think Ruby had a long type!

Time to RTFM (Incidentally, I always use gotapi for browsing and searching API docs). No mention of longs in the description, but a peek at the source shows that the underlying C code is doing things like beg = NUM2LONG(argv[0]);. Not to worry though – it's not like I'm going to be just creating an array of all possible values, so I'll just check the upper range explicitly too.

  def to_words
    return '?' unless (1..9).include? self
    numbers = [nil, 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine']
    numbers[self]
  end
..............

Finished in 0.023524 seconds

14 examples, 0 failures

Right, now we can get back to the real task. The words for 10–19 are mostly irregular, so I'll just add them to the list for 1–9.

# 1-19
{1=>'one', 2=>'two', 3=>'three', 4=>'four', 5=>'five', 6=>'six', 7=>'seven',
  8=>'eight', 9=>'nine', 10=>'ten', 11=>'eleven', 12=>'twelve', 13=>'thirteen',
  14=>'fourteen', 15=>'fifteen', 16=>'sixteen', 17=>'seventeen',
  18=>'eighteen', 19=>'nineteen'}.each do |number, word|
  describe "#{number}.to_words" do
    it "should be '#{word}'" do
      number.to_words.should == word
    end
  end
end
.....F.FF.F.F.F.F.F..F.F

1)
'16.to_words should be 'sixteen'' FAILED
expected "sixteen", got "?" (using ==)
./to_words_spec.rb:19:

…

10)
'10.to_words should be 'ten'' FAILED
expected "ten", got "?" (using ==)
./to_words_spec.rb:19:

Finished in 0.040177 seconds

24 examples, 10 failures

I'll just add them to the array in the code too (remembering to increase the allowable range):

  def to_words
    return '?' unless (1..19).include? self
    numbers = [nil, 'one', 'two', 'three', 'four', 'five', 'six', 'seven',
      'eight', 'nine', 'ten', 'eleven', 'twelve', 'thirteen', 'fourteen',
      'fifteen', 'sixteen', 'seventeen', 'eighteen', 'nineteen']
    numbers[self]
  end
...F....................

1)
'10.to_words should be '?'' FAILED
expected "?", got "ten" (using ==)
./to_words_spec.rb:7:

Finished in 0.039858 seconds

24 examples, 1 failure

Oops, forgot to modify the examples for out-of-range behaviour.

# Examples of unhandled numbers
[-123, -1, 0, 20, 10_000_000_001].each do |number|
  describe "#{number}.to_words" do
    it "should be '?'" do
      number.to_words.should == '?'
    end
  end
end
# Examples of unhandled numbers
........................

Finished in 0.060643 seconds

24 examples, 0 failures

Now let's do 20, 30 etc. Once they're done, that should be it for the actual numbers, and we can start stringing them together.

# 20, 30 ... 90
{20=>'twenty', 30=>'thirty', 40=>'forty', 50=>'fifty', 60=>'sixty',
  70=>'seventy', 80=>'eighty', 90=>'ninety'}.each do |number, word|
  describe "#{number}.to_words" do
    it "should be '#{word}'" do
      number.to_words.should == word
    end
  end
end
........................FFFFFFFF

1)
'60.to_words should be 'sixty'' FAILED
expected "sixty", got "?" (using ==)
./to_words_spec.rb:29:

…

8)
'70.to_words should be 'seventy'' FAILED
expected "seventy", got "?" (using ==)
./to_words_spec.rb:29:

Finished in 0.045908 seconds

32 examples, 8 failures

There's going to be extra logic required for numbers over twenty (so far we're only looking at the round ones, and ignoring 21, 22 etc), so I'll split them out in the code.

  def to_words
    return '?' unless (1..99).include? self
    numbers = [nil, 'one', 'two', 'three', 'four', 'five', 'six', 'seven',
      'eight', 'nine', 'ten', 'eleven', 'twelve', 'thirteen', 'fourteen',
      'fifteen', 'sixteen', 'seventeen', 'eighteen', 'nineteen']
    decades = [nil, nil, 'twenty', 'thirty', 'forty', 'fifty', 'sixty',
      'seventy', 'eighty', 'ninety']
    case self
      when 1..19
        numbers[self]
      when 20..99
        decades[self/10]
    end
  end

This time I remember to change the out-of-range example too:

[-123, -1, 0, 100, 10_000_000_001].each do |number|
  describe "#{number}.to_words" do
    it "should be '?'" do
      number.to_words.should == '?'
    end
  end
end
................................

Finished in 0.053969 seconds

32 examples, 0 failures

Looking good. Now to start building up more interesting numbers. I'll word the examples a bit differently from now on, so that it's obvious what general behaviour they're actually describing. Let's start with two digit numbers.

describe "A two-digit number above 20 that's not divisible by ten, in words" do
  it "should be '-'" do
    21.to_words.should == 'twenty-one'
  end
end
................................F

1)
'A two-digit number above 20 that's not divisible by ten, in words should be '-'' FAILED
expected "twenty-one", got "twenty" (using ==)
./to_words_spec.rb:36:

Finished in 0.051832 seconds

33 examples, 1 failure

First of all, I'll take the naive approach of adding the hyphen and the units number every time.

      when 20..99
        decades[self/10] + '-' + numbers[self%10]
........................FFFFFFFF.

1)
TypeError in '60.to_words should be 'sixty''
can't convert nil into String
./to_words.rb:13:in `+'
./to_words.rb:13:in `to_words'
./to_words_spec.rb:29:

…

8)
TypeError in '70.to_words should be 'seventy''
can't convert nil into String
./to_words.rb:13:in `+'
./to_words.rb:13:in `to_words'
./to_words_spec.rb:29:

Finished in 0.049627 seconds

33 examples, 8 failures

My new example passes, but as expected I've broken a load of others. This is good, because it shows that I'm building up a good test coverage. A slightly more sensible implementation:

      when 20..99
        decades[self/10] + (self%10 == 0 ? '' : ('-' + numbers[self%10]))
.................................

Finished in 0.04624 seconds

33 examples, 0 failures

It's probably worth trying another couple of numbers too, including some boundary conditions. I'm not really sure what text to put in the specs now though, as they're just different examples of the same behaviour. I could create a new context and call it 'Another two-digit number…', but I think I'll just give them both the same name. This isn't something I've done before, so I haven't decided yet whether it's a good thing or not. I'll factor out the duplication as I go.

describe "A two-digit number above 20 that's not divisible by ten, in words" do
  {21=>'twenty-one', 42=>'forty-two', 69=>'sixty-nine',
    99=>'ninety-nine'}.each do |number, word|
    it "should be '-'" do
      number.to_words.should == word
    end
  end
end
....................................

Finished in 0.048549 seconds

36 examples, 0 failures

Good, it still works. I could add more, but I think that's enough.

Next thing to attack is probably the round hundreds. I've decided there's no point in having to change the out-of-range example every time, so I've changed it to only try the really big number.

# Examples of unhandled numbers
[-123, -1, 0, 10_000_000_001].each do |number|
  describe "#{number}.to_words" do
    it "should be '?'" do
      number.to_words.should == '?'
    end
  end
end

...

describe "A three-digit number divisible by one hundred, in words" do
  [1, 5, 9].each do |digit|
    it "should be ' hundred'" do
      (digit*100).to_words.should == digit.to_words + ' hundred'
    end
  end
end

Note how I've used to_words itself in the matcher argument. I can get away with that because I've already tested its behaviour for the numbers I'm using.

...................................FFF

1)
'A three-digit number that's divisible by one hundred, in words should be ' hundred'' FAILED
expected "one hundred", got "?" (using ==)
./to_words_spec.rb:46:

2)
'A three-digit number that's divisible by one hundred, in words should be ' hundred'' FAILED
expected "five hundred", got "?" (using ==)
./to_words_spec.rb:46:

3)
'A three-digit number that's divisible by one hundred, in words should be ' hundred'' FAILED
expected "nine hundred", got "?" (using ==)
./to_words_spec.rb:46:

Finished in 0.049282 seconds

38 examples, 3 failures

Should be easy enough…

  def to_words
    return '?' unless (1..10_000_000_000).include? self
    numbers = [nil, 'one', 'two', 'three', 'four', 'five', 'six', 'seven',
      'eight', 'nine', 'ten', 'eleven', 'twelve', 'thirteen', 'fourteen',
      'fifteen', 'sixteen', 'seventeen', 'eighteen', 'nineteen']
    decades = [nil, nil, 'twenty', 'thirty', 'forty', 'fifty', 'sixty',
      'seventy', 'eighty', 'ninety']
    case self
      when 1..19
        numbers[self]
      when 20..99
        decades[self/10] + (self%10 == 0 ? '' : ('-' + numbers[self%10]))
      when 100...999
        numbers[self/100] + ' hundred'
    end
  end
......................................

Finished in 0.048804 seconds

38 examples, 0 failures

Let's fill in the gaps between the hundreds.

describe "A three-digit number that's not divisible by one hundred, in words" do
  [101, 150, 666, 999].each do |number|
    it "should be ' hundred and '" do
      number.to_words.should == (number/100).to_words + ' hundred and ' + (number%100).to_words
    end
  end
end
......................................FFFF

1)
'A three-digit number that's not divisible by one hundred, in words should be ' hundred and '' FAILED
expected "one hundred and one", got "one hundred" (using ==)
./to_words_spec.rb:54:

2)
'A three-digit number that's not divisible by one hundred, in words should be ' hundred and '' FAILED
expected "one hundred and fifty", got "one hundred" (using ==)
./to_words_spec.rb:54:

3)
'A three-digit number that's not divisible by one hundred, in words should be ' hundred and '' FAILED
expected "six hundred and sixty-six", got "six hundred" (using ==)
./to_words_spec.rb:54:

4)
'A three-digit number that's not divisible by one hundred, in words should be ' hundred and '' FAILED
expected "nine hundred and ninety-nine", got nil (using ==)
./to_words_spec.rb:54:

Finished in 0.056888 seconds

42 examples, 4 failures
      when 100...999
        numbers[self/100] + ' hundred' + (self%100 == 0 ? '' : ' and ' + (self%100).to_words)
.........................................F

1)
'A three-digit number that's not divisible by one hundred, in words should be ' hundred and '' FAILED
expected "nine hundred and ninety-nine", got nil (using ==)
./to_words_spec.rb:54:

Finished in 0.050711 seconds

42 examples, 1 failure

That's odd. Oh, I see – there's an extra dot in the range (100...999 instead of 100..99), which means it excludes the last number. Let's fix that and run it again.

      when 100..999
        numbers[self/100] + ' hundred' + (self%100 == 0 ? '' : ' and ' + (self%100).to_words)
..........................................

Finished in 0.054811 seconds

42 examples, 0 failures

That's better.

From now on, it ought to be plain sailing. Each group of three digits needs to get converted on its own, then have the appropriate multiplier ('thousand', 'million' or 'billion') appended (and maybe a comma). Let's start with the thousands.

describe "A four, five or six-digit number that's divisible by one thousand, in words" do
  [1_000, 23_000, 456_000, 999_000].each do |number|
    it "should be ' thousand'" do
      number.to_words.should == (number/1000).to_words + ' thousand'
    end
  end
end
..........................................FFFF

1)
'A four, five or six-digit number that's divisible by one thousand, in words should be ' thousand'' FAILED
expected "one thousand", got nil (using ==)
./to_words_spec.rb:62:

…

4)
'A four, five or six-digit number that's divisible by one thousand, in words should be ' thousand'' FAILED
expected "nine hundred and ninety-nine thousand", got nil (using ==)
./to_words_spec.rb:62:

Finished in 0.072061 seconds

46 examples, 4 failures
    case self
      when 1..19
        numbers[self]
      when 20..99
        decades[self/10] + (self%10 == 0 ? '' : ('-' + numbers[self%10]))
      when 100..999
        numbers[self/100] + ' hundred' + (self%100 == 0 ? '' : ' and ' + (self%100).to_words)
      when 1_000..999_999
        (self/1000).to_words + ' thousand'
    end
..............................................

Finished in 0.053842 seconds

46 examples, 0 failures
describe "A four, five or six-digit number that's not divisible by one thousand, in words" do
  [1_234, 23_456, 345_678, 999_999].each do |number|
    it "should be ' thousand, '" do
      number.to_words.should == (number/1000).to_words + ' thousand, ' + (number%1000).to_words
    end
  end
end
..............................................FFFF

1)
'A four, five or six-digit number that's not divisible by one thousand, in words should be ' thousand, '' FAILED
expected "one thousand, two hundred and thirty-four", got "one thousand" (using ==)
./to_words_spec.rb:70:

…

4)
'A four, five or six-digit number that's not divisible by one thousand, in words should be ' thousand, '' FAILED
expected "nine hundred and ninety-nine thousand, nine hundred and ninety-nine", got "nine hundred and ninety-nine thousand" (using ==)
./to_words_spec.rb:70:

Finished in 0.05905 seconds

50 examples, 4 failures
      when 1_000..999_999
        (self/1000).to_words + ' thousand' + (self%1000 == 0 ? '' : ', ' + (self%1000).to_words)
..................................................

Finished in 0.056001 seconds

50 examples, 0 failures

Flying along! There's a special case we need to cover before we move onto the millions though – where there are no hundreds, so the comma becomes an 'and' (like 'two thousand and seven').

describe "A four, five or six-digit number that's not divisible by one thousand but has no hundreds, in words" do
  [1_023, 23_001, 345_099].each do |number|
    it "should be ' thousand and '" do
      number.to_words.should == (number/1000).to_words + ' thousand and ' + (number%1000).to_words
    end
  end
end
..................................................FFF

1)
'A four, five or six-digit number that's not divisible by one thousand but has no hundreds, in words should be ' thousand and '' FAILED
expected "one thousand and twenty-three", got "one thousand, twenty-three" (using ==)
./to_words_spec.rb:78:

2)
'A four, five or six-digit number that's not divisible by one thousand but has no hundreds, in words should be ' thousand and '' FAILED
expected "twenty-three thousand and one", got "twenty-three thousand, one" (using ==)
./to_words_spec.rb:78:

3)
'A four, five or six-digit number that's not divisible by one thousand but has no hundreds, in words should be ' thousand and '' FAILED
expected "three hundred and forty-five thousand and ninety-nine", got "three hundred and forty-five thousand, ninety-nine" (using ==)
./to_words_spec.rb:78:

Finished in 0.060685 seconds

53 examples, 3 failures
      when 1_000..999_999
        (self/1000).to_words + ' thousand' +
          if self%1000 == 0
            ''
          else
            (self%1000 < 100 ? ' and ' : ', ') + (self%1000).to_words
          end
.....................................................

Finished in 0.057426 seconds

53 examples, 0 failures

The millions should be pretty much the same, so I'll do all the specs at once.

describe "A seven, eight or nine-digit number that's divisible by one million, in words" do
  [1_000_000, 34_000_000, 567_000_000, 999_000_000].each do |number|
    it "should be ' million'" do
      number.to_words.should == (number/1_000_000).to_words + ' million'
    end
  end
end

describe "A seven, eight or nine-digit number that's not divisible by one million, in words" do
  [1_234_567, 34_567_890, 567_890_123, 999_999_999].each do |number|
    it "should be ' million, '" do
      number.to_words.should == (number/1_000_000).to_words + ' million, ' + (number%1_000_000).to_words
    end
  end
end

describe "A seven, eight or nine-digit number that's not divisible by one million but has zeroes " +
  "from hundreds of thousands down to hundreds, in words" do
  [1_000_023, 23_000_001, 345_000_099].each do |number|
    it "should be ' million and '" do
      number.to_words.should == (number/1_000_000).to_words + ' million and ' + (number%1_000_000).to_words
    end
  end
end
.....................................................FFFFFFFFFFF

1)
'A seven, eight or nine-digit number that's divisible by one million, in words should be ' million'' FAILED
expected "one thousand million", got nil (using ==)
./to_words_spec.rb:86:

…

11)
'A seven, eight or nine-digit number that's not divisible by one million but has zeroes from hundreds of thousands down to hundreds, in words should be ' million and '' FAILED
expected "three hundred and forty-five million and ninety-nine", got nil (using ==)
./to_words_spec.rb:103:

Finished in 0.074188 seconds

64 examples, 11 failures

      when 1_000_000..999_999_999
        (self/1_000_000).to_words + ' thousand' +
          if self%1_000_000 == 0
            ''
          else
            (self%1_000_000 < 100 ? ' and ' : ', ') + (self%1_000_000).to_words
          end
................................................................

Finished in 0.066676 seconds

64 examples, 0 failures

Just the billions to go, and we're done! I might as well let it handle up to 999,999,999,999 – forcing it to stop at 10,000,000,000 would probably be harder.

# Examples of unhandled numbers
[-123, -1, 0, 1_000_000_000_000].each do |number|
  describe "#{number}.to_words" do
    it "should be '?'" do
      number.to_words.should == '?'
    end
  end
end

...

describe "A ten, eleven or twelve-digit number that's divisible by one billion, in words" do
  [1_000_000_000, 34_000_000_000, 567_000_000_000, 999_000_000_000].each do |number|
    it "should be ' billion'" do
      number.to_words.should == (number/1_000_000_000).to_words + ' billion'
    end
  end
end

describe "A ten, eleven or twelve-digit number that's not divisible by one billion, in words" do
  [1_234_567_890, 34_567_890_123, 567_890_123_456, 999_999_999_999].each do |number|
    it "should be ' billion, '" do
      number.to_words.should == (number/1_000_000_000).to_words + ' billion, ' + (number%1_000_000_000).to_words
    end
  end
end

describe "A ten, eleven or twelve-digit number that's not divisible by one billion but has zeroes " +
  "from hundreds of millions down to hundreds, in words" do
  [1_000_000_023, 23_000_000_001, 345_000_000_099].each do |number|
    it "should be ' billion and '" do
      number.to_words.should == (number/1_000_000_000).to_words + ' billion and ' + (number%1_000_000_000).to_words
    end
  end
end
................................................................FFFFFFFFFFF

1)
'A ten, eleven or twelve-digit number that's divisible by one billion, in words should be ' billion'' FAILED
expected "one billion", got nil (using ==)
./to_words_spec.rb:111:

–

11)
'A ten, eleven or twelve-digit number that's not divisible by one billion but has zeroes from hundreds of millions down to hundreds, in words should be ' billion and '' FAILED
expected "three hundred and forty-five billion and ninety-nine", got "?" (using ==)
./to_words_spec.rb:128:

Finished in 0.105984 seconds

75 examples, 11 failures

The complete working code:

Integer.class_eval do
  def to_words
    return '?' unless (1..999_999_999_999).include? self
    numbers = [nil, 'one', 'two', 'three', 'four', 'five', 'six', 'seven',
      'eight', 'nine', 'ten', 'eleven', 'twelve', 'thirteen', 'fourteen',
      'fifteen', 'sixteen', 'seventeen', 'eighteen', 'nineteen']
    decades = [nil, nil, 'twenty', 'thirty', 'forty', 'fifty', 'sixty',
      'seventy', 'eighty', 'ninety']
    case self
      when 1..19
        numbers[self]
      when 20..99
        decades[self/10] + (self%10 == 0 ? '' : ('-' + numbers[self%10]))
      when 100..999
        numbers[self/100] + ' hundred' + (self%100 == 0 ? '' : ' and ' + (self%100).to_words)
      when 1_000..999_999
        (self/1000).to_words + ' thousand' +
          if self%1000 == 0
            ''
          else
            (self%1000 < 100 ? ' and ' : ', ') + (self%1000).to_words
          end
      when 1_000_000..999_999_999
        (self/1_000_000).to_words + ' million' +
          if self%1_000_000 == 0
            ''
          else
            (self%1_000_000 < 100 ? ' and ' : ', ') + (self%1_000_000).to_words
          end
      when 1_000_000_000..999_999_999_999
        (self/1_000_000_000).to_words + ' billion' +
          if self%1_000_000_000 == 0
            ''
          else
            (self%1_000_000_000 < 100 ? ' and ' : ', ') + (self%1_000_000_000).to_words
          end
    end
  end
end

And just to prove it:

...........................................................................

Finished in 0.099379 seconds

75 examples, 0 failures

I think it's interesting how the recursive calls back into to_words just kind of fell into place as I went along, without particularly thinking about it. I think if I'd tried to design that up-front it would have involved a lot of head-scratching, and it would have been harder to pick suitable test cases to prove it worked.

Just a little bit of cleaning up before I call it a day – those last three clauses in the case statement look a bit repetitive, so let's see if we can do a bit of refactoring.

Integer.class_eval do
  def to_words
    return '?' unless (1..999_999_999_999).include? self
    numbers = [nil, 'one', 'two', 'three', 'four', 'five', 'six', 'seven',
      'eight', 'nine', 'ten', 'eleven', 'twelve', 'thirteen', 'fourteen',
      'fifteen', 'sixteen', 'seventeen', 'eighteen', 'nineteen']
    decades = [nil, nil, 'twenty', 'thirty', 'forty', 'fifty', 'sixty',
      'seventy', 'eighty', 'ninety']
    case self
      when 1..19
        numbers[self]
      when 20..99
        decades[self/10] + (self%10 == 0 ? '' : ('-' + numbers[self%10]))
      when 100..999
        numbers[self/100] + ' hundred' + (self%100 == 0 ? '' : ' and ' + (self%100).to_words)
      when 1_000..999_999
        words_for_big_numbers 1_000, 'thousand'
      when 1_000_000..999_999_999
        words_for_big_numbers 1_000_000, 'million'
      when 1_000_000_000..999_999_999_999
        words_for_big_numbers 1_000_000_000, 'billion'
    end
  end
  
  private
  def words_for_big_numbers multiplier_value, multiplier_name
    (self/multiplier_value).to_words + ' ' + multiplier_name +
      if self%multiplier_value == 0
        ''
      else
        (self%multiplier_value < 100 ? ' and ' : ', ') + (self%multiplier_value).to_words
      end
  end
end

Run the specs one last time, to make sure the refactoring didn't break anything:

...........................................................................

Finished in 0.099923 seconds

75 examples, 0 failures

Finished!

On the off-chance that anyone actually read this far, I hope it was at least mildly interesting. I didn't expect to end up with quite such a long post when I started!

Postscript: the 'extra credit' question

This isn't part of the main purpose of the post, because I'm not going to test-drive it, but I thought I might as well include the final part of the problem too:

For an extra challenge, when the strings for the numbers for 1 - 10,000,000,000 are sorted alphabetically, which is the
first odd number in the list?

I'm not sure whether this will complete in a reasonable time, but I'll try a brute force approach – just loop through all the odd numbers and keep track of the one that's earliest, alphabetically speaking. I'm going to start with a much smaller range (up to a million), and put a progress indicator of sorts in so I can see whether it's getting anywhere at all.

require 'to_words'

winner = ((1...500_000).collect {|n| n * 2 - 1}.inject('zzz') do |earliest, current|
  puts "#{current/10_000}% complete" if current%10_000 == 1
  word = current.to_words
  word < earliest ? word : earliest
end)
puts winner
Kerrys-G5:~/Dev/RFG kerry$ irb

irb(main):001:0> require 'benchmark'
=> true
irb(main):002:0> puts Benchmark.measure { load 'extra_credit.rb' }
0% complete
1% complete
...
99% complete
eight hundred and eight thousand and eighty-five
115.470000   1.060000 116.530000 (119.292097)

So, two minutes to run the first million – I make that about a fortnight to do ten billion. Time to be a bit more creative.

Thinking about it, eight is obviously the earliest number in the alphabet, and five the earliest odd number, so whatever the answer is, it's going to end in a five, with all the other digits being either zero or eight.

require 'to_words'

# find all the nine-digit numbers made up of only zeros and eights
possibilities = [0, 8]
8.times do
  new_possibilities = []
  possibilities.each do |possibility|
    new_possibilities << possibility * 10
    new_possibilities << possibility * 10 + 8
  end
  possibilities += new_possibilities
end

# add a five to all of them
possibilities.collect! {|p| p * 10 + 5}

winner = possibilities.inject('zzz') do |earliest, current|
  word = current.to_words
  raise "Unexpected number: #{current}" if word.eql? '?'
  word < earliest ? word : earliest
end
puts winner
irb(main):003:0> puts Benchmark.measure { load 'extra_credit.rb' }
eight billion and eighty-five
  3.110000   0.030000   3.140000 (  3.149860)

So, if my logic makes sense, the answer is 8,000,000,085. And three seconds is a bit kinder to my CPU than a fortnight!

Final files

Leave a Reply