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!