Skip to content

Instantly share code, notes, and snippets.

@eamodeorubio
Created January 6, 2011 18:51
Show Gist options
  • Save eamodeorubio/768329 to your computer and use it in GitHub Desktop.
Save eamodeorubio/768329 to your computer and use it in GitHub Desktop.
The string calculator kata step by step
# StringCalculator in Ruby 1.8.x
class StringCalculator
DEFAULT_DELIMITER_DETECTOR = /\,|\n/
CUSTOM_DELIMITER_PARSERS = [/^\/\/(.)\n/, /^\/\/((?:\[[^\]]+\])+)\n/]
def initialize(numbers)
@numbers=numbers
@result=nil
@errors=''
@delimiterDetectorRegExp=DEFAULT_DELIMITER_DETECTOR
detect_and_consume_custom_delimiter_if_any
end
def total_sum
calculate_sum unless @result
raise "Negative numbers detected: #{@errors}" unless @errors.empty?
@result
end
private
def calculate_sum
@result = 0
@numbers.split(@delimiterDetectorRegExp).each do |currentNumber|
currentNumber = currentNumber.to_i
@errors << " #{currentNumber}" if currentNumber < 0
@result += currentNumber if currentNumber <= 1000
end
end
def detect_and_consume_custom_delimiter_if_any
CUSTOM_DELIMITER_PARSERS.detect do |customDelimiterDetectorRegExp|
if customDelimiterMatch = customDelimiterDetectorRegExp.match(@numbers)
extractAllDelimiters(customDelimiterMatch[1])
@numbers = @numbers[customDelimiterMatch.end(0)..-1]
end
end
end
def extractAllDelimiters(delimiterExpression)
return @delimiterDetectorRegExp = Regexp.union(@delimiterDetectorRegExp, Regexp.new(Regexp.escape(delimiterExpression))) if delimiterExpression.length == 1
delimiterExpression.scan(/\[([^\]]+)\]/) do |delimiter|
@delimiterDetectorRegExp = Regexp.union(@delimiterDetectorRegExp, Regexp.new(Regexp.escape(delimiter[0])))
end
end
end
describe StringCalculator do
context "Sums 0, 1 or 2 numbers" do
it "returns 0 when passed an empty string" do
StringCalculator.new('').total_sum.should == 0
end
it "returns the number when passed only one number" do
StringCalculator.new('1').total_sum.should == 1
StringCalculator.new('12').total_sum.should == 12
StringCalculator.new('24').total_sum.should == 24
StringCalculator.new('35').total_sum.should == 35
end
it "returns the sum of two numbers separated by commas" do
StringCalculator.new('1,2').total_sum.should == 3
StringCalculator.new('12,34').total_sum.should == 46
StringCalculator.new('24,10').total_sum.should == 34
StringCalculator.new('46,34').total_sum.should == 80
end
end
context "Sums any number of integers" do
it "returns 13 when passed '1,10,2'" do
StringCalculator.new("1,10,2").total_sum.should == 13
end
it "returns 254 when passed '21,1,232'" do
StringCalculator.new("21,1,232").total_sum.should == 254
end
it "returns 112 when passed '100,5,6,1'" do
StringCalculator.new("100,5,6,1").total_sum.should == 112
end
it "returns 153 when passed '10,11,102,30'" do
StringCalculator.new("10,11,102,30").total_sum.should == 153
end
end
context "It deals with \\n as a delimiter" do
it "returns 46 when passed '12\\n34'" do
StringCalculator.new("12\n34").total_sum.should == 46
end
it "returns 13 when passed '1,10\\n2'" do
StringCalculator.new("1,10\n2").total_sum.should == 13
end
it "returns 254 when passed '21\\n1,232'" do
StringCalculator.new("21\n1,232").total_sum.should == 254
end
it "returns 112 when passed '100\\n5\\n6\\n1'" do
StringCalculator.new("100\n5\n6\n1").total_sum.should == 112
end
it "returns 153 when passed '10,11\\n102\\n30'" do
StringCalculator.new("10,11\n102\n30").total_sum.should == 153
end
it "returns 112 when passed '100\\n5\\n6,1'" do
StringCalculator.new("100\n5\n6,1").total_sum.should == 112
end
it "returns 153 when passed '10\\n11,102\\n30'" do
StringCalculator.new("10\n11,102\n30").total_sum.should == 153
end
end
context "It deals with an arbitrary delimiter specified with '//delimiter\\n'" do
it "returns 46 when passed '//;\\n12;34'" do
StringCalculator.new("//;\n12;34").total_sum.should == 46
end
it "returns 13 when passed '//*\\n1*10\\n2'" do
StringCalculator.new("//*\n1*10\n2").total_sum.should == 13
end
it "returns 13 when passed '//*\\n1\\n10*2'" do
StringCalculator.new("//*\n1\n10*2").total_sum.should == 13
end
it "returns 153 when passed '//-\\n10-11\\n102-30'" do
StringCalculator.new("//-\n10-11\n102-30").total_sum.should == 153
end
end
context "It raises an error when passed negative numbers including the offending numbers" do
it "raises an error containing -22 when passed '1,-22'" do
lambda { StringCalculator.new("1,-22").total_sum }.should raise_error { |error| error.message.should include("-22") }
end
it "raises an error containing -22,-2 and -15 when passed '1,-22,33,-2,44,-15'" do
lambda { StringCalculator.new("1,-22,33,-2,44,-15").total_sum }.should raise_error { |error| error.message.should include("-22","-2","-15") }
end
end
context "Ignores numbers above 1000" do
it "returns 35 when passed '1,1002,34'" do
StringCalculator.new("1,1002,34").total_sum.should == 35
end
it "returns 135 when passed '1,1002,34,50,2353,50'" do
StringCalculator.new("1,1002,34,50,2353,50").total_sum.should == 135
end
end
context "It deals with a custom delimiter of variable length specified with '//[delimiter]\\n'" do
it "returns 46 when passed '//[;**]\\n12;**34'" do
StringCalculator.new("//[;**]\n12;**34").total_sum.should == 46
end
it "returns 13 when passed '//[*-*]\\n1*-*10\\n2'" do
StringCalculator.new("//[*-*]\n1*-*10\n2").total_sum.should == 13
end
it "returns 13 when passed '//[iop]\\n1\\n10iop2'" do
StringCalculator.new("//[iop]\n1\n10iop2").total_sum.should == 13
end
it "returns 153 when passed '//[-99-]\\n10-99-11\\n102-99-30'" do
StringCalculator.new("//[-99-]\n10-99-11\n102-99-30").total_sum.should == 153
end
end
context "It deals with a several custom delimiter '//[delimiter1][delimiter2]\\n'" do
it "returns 76 when passed '//[;**][%]\\n12;**34%10\n20'" do
StringCalculator.new("//[;**][%]\n12;**34%10\n20").total_sum.should == 76
end
it "returns 53 when passed '//[*-][$$][&]\\n1*-*10\\n2$$15&25'" do
StringCalculator.new("//[*-*][$$][&]\n1*-*10\n2$$15&25").total_sum.should == 53
end
end
end
@eamodeorubio
Copy link
Author

Initial contents

@eamodeorubio
Copy link
Author

Test and implementation for only one number

@eamodeorubio
Copy link
Author

Added test and implementation for two numbers. Need code clean up !

@eamodeorubio
Copy link
Author

First refactor. Removed some duplication but I don't really like this code. Need to think more.

@eamodeorubio
Copy link
Author

I added tests for any count of numbers to sum. I implemented it in a copy&paste way to show a clear pattern of duplication. Time to refactor.

@eamodeorubio
Copy link
Author

We clearly were looking for commas in the string, extract the number and sum it, then look the next comma, extract the number and sum. It can be clearly be replaced by a loop, an iteration or a recursive call. I choose a recursivity.

@eamodeorubio
Copy link
Author

Added test for allowing \n as a separator. Instead of looking for comma now we looks for the regular expression meaning "comma or \n"

@eamodeorubio
Copy link
Author

Implemented single character custom delimiter. Notice I've moved the recursive algorithm to an auxiliar method. There are two reasons:

  • Single Responsability Principle. I do not want a method doing two unrelated things: parsing the custom delimiter, and calculating the sum.
  • The sum algorithm is recursive and I want to detect the delimiter only once

@eamodeorubio
Copy link
Author

Refactored to improve readability

@eamodeorubio
Copy link
Author

Refactored to use only one base case for recursivity.

@eamodeorubio
Copy link
Author

Thanks to last change I can now refactor. I extract a method from add_numbers, called consume_next_number that extract the next number in the string. Returns the next number in the string and the rest of the string. This way the code is more readable and the methods are more focused.

@eamodeorubio
Copy link
Author

Raising error when only a negative number is passed. Simple implementation

@eamodeorubio
Copy link
Author

Test and implementation for several negative numbers. Notice I added the parameter errors to collect all the negative numbers found. If at the end (base case) there are errors then raise the exception

@eamodeorubio
Copy link
Author

Ignoring numbers above 1000

@eamodeorubio
Copy link
Author

A bit of refactoring. Emphasize that negative and greater than 1000 numbers are treated specially

@eamodeorubio
Copy link
Author

Arbitrary length delimiters. Time to refactor !

@eamodeorubio
Copy link
Author

Forgot to paste the test ! I'm sorry

@eamodeorubio
Copy link
Author

Refactored. The two different parsing expressions for custom delimiters are stored in a constant array. This constant is iterated looking for the first one matching. If match calculate delimiter detector reg exp and consume the first line. If not return default delimiter detector (that is now a reg exp too instead a string)

@eamodeorubio
Copy link
Author

Refactor to delete duplication in method arguments. To do so we do a more OO design. Created a constructor with number and the delimiter regexp as instance variables. They can now be removed from the method signature. Tests fails, need to be refactored too.

@eamodeorubio
Copy link
Author

Tests refactored to the new design. Note the method is called total_sum instead of add_numbers, it is more readable in the new design.

@eamodeorubio
Copy link
Author

Refactored again. Changed recursive design to an iterative one. It is more compact and we need to instantiate only one object eliminating the need to pass the regexp from object to object. This design hides better the information.
The recursive algorithm was spliting the string using the separator reg exp. I use the split method that do the same and returns an array.

@eamodeorubio
Copy link
Author

Ooops ! Bad formatted code. Now its fine. Damned copy&paste !

@eamodeorubio
Copy link
Author

One last refactor. Transform error and the result in instance variables allows us to:

  • Compute only once the result. Once computed cache it.
  • Remove the "process_special_...." method since no more clarifies the intent.

@eamodeorubio
Copy link
Author

Final version. Supporting several custom delimiters of arbitrary length.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment