As you may recall, before life (real life, not Conway's) got rather busy, I left you hanging off the cliff of Dave Thomas's Code Kata #9. I had finished the "just make it work" part. Now for the "decouple the price rule format" part.
As Dave (the other Dave) says, there are many ways to do it. I chose to use the Strategy Pattern. (If you're not familiar with design patterns, you really need to go read up on them.)
As you can see in the code below, the piece that gets swapped out is actually very small. It does nothing but parse the pricing rules. How they get applied remains the same.
#! /usr/bin/ruby # Dave Thomas Code Kata #9: Supermarket Checkout # See http://codekata.pragprog.com/2007/01/kata_nine_back_.html # Solution by Dave Aronson # VERSION B: decouple the pricing format. class CheckOut def initialize rules, parser @items = {} # only really need this in total, but IRL a customer # might just want a price-check on something.... @pricer = Pricer.new rules, parser end def scan sku @items[sku] = 0 if @items[sku].nil? @items[sku] += 1 end def total tot = 0 @items.each_pair { |sku, qty| tot += @pricer.price sku, qty } tot end end # separate from CheckOut 'cuz IRL there may be price-check stations class Pricer def initialize rules, parser @prices = parser.parse rules end def price sku, quantity qty = quantity tot = 0 while qty > 0 do deal_qty, deal_price = find_best_deal sku, qty num_deals = qty / deal_qty tot += num_deals * deal_price qty -= num_deals * deal_qty end tot end private def find_best_deal sku, qty prices = @prices[sku] if prices.nil? puts "ERROR: NO PRICE FOUND for #{sku}!" return nil end deal = nil prices.each_pair do |rule_qty, rule_tot| if rule_qty > qty then break else # Inefficient to keep overwriting this, # but shouldn't happen too much.... deal = [rule_qty, rule_tot] end end puts "ERROR: NO PRICE FOUND for #{qty} of #{sku}!" if deal.nil? deal end end # now we get to the heart of the matter: # different ways to parse prices. # essentially the strategy pattern. class CSVPriceParser def parse rules prices = {} rules.each do |line| line.chomp! cur_sku, qty, tot = line.split "," prices[cur_sku] = {} if prices[cur_sku].nil? prices[cur_sku][qty.to_i] = tot.to_i end prices end end CSV_RULES = [ "A,1,50", "A,3,130", "B,1,30", "B,2,45", "C,1,20", "D,1,15", ] class YAMLPriceParser def parse rules prices = {} cur_sku = '' rules.each do |line| line.chomp! if line[0..0] == "\t" qty, tot = line[1..-1].split "\t" # 1 to ignore tab # really should check for cur_sku not being set yet, # though that would mean it's an invalid file.... prices[cur_sku] = {} if prices[cur_sku].nil? prices[cur_sku][qty.to_i] = tot.to_i else cur_sku = line end end prices end end YAML_RULES = [ "A", "\t1\t50", "\t3\t130", "B", "\t1\t30", "\t2\t45", "C", "\t1\t20", "D", "\t1\t15", ] # TESTS, initially supplied by Dave Thomas, # but refactored by Dave Aronson # to support swapping out strategies require 'test/unit' class TestPrice < Test::Unit::TestCase def helper_test_incremental rules, parser co = CheckOut.new rules, parser assert_equal 0, co.total co.scan "A"; assert_equal( 50, co.total) co.scan "B"; assert_equal( 80, co.total) co.scan "A"; assert_equal(130, co.total) co.scan "A"; assert_equal(160, co.total) co.scan "B"; assert_equal(175, co.total) end def helper_test_totals rules, parser assert_equal( 0, price("", rules, parser)) assert_equal( 50, price("A", rules, parser)) assert_equal( 80, price("AB", rules, parser)) assert_equal(115, price("CDBA", rules, parser)) assert_equal(100, price("AA", rules, parser)) assert_equal(130, price("AAA", rules, parser)) assert_equal(180, price("AAAA", rules, parser)) assert_equal(230, price("AAAAA", rules, parser)) assert_equal(260, price("AAAAAA", rules, parser)) assert_equal(160, price("AAAB", rules, parser)) assert_equal(175, price("AAABB", rules, parser)) assert_equal(190, price("AAABBD", rules, parser)) assert_equal(190, price("DABABA", rules, parser)) end def price goods, rules, parser co = CheckOut.new rules, parser goods.split(//).each { |item| co.scan(item) } co.total end def helper_test rules, parser helper_test_incremental rules, parser helper_test_totals rules, parser end def test_rulesets helper_test CSV_RULES, CSVPriceParser.new helper_test YAML_RULES, YAMLPriceParser.new end end
As always (just had to continue starting each paragraph with "As"!), it's now your turn. How would you have designed and/or implemented this differently? Leave a comment.