Saturday, November 20, 2010

Code Kata Nine: Supermarket Checkout, Part B (Decouple)

   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.

Sunday, November 14, 2010

And now for something completely different...

   Okay, maybe not completely different, as it will still be code-related.  But, coding time is at quite a premium right now.  So, until I manage to get some time to sit down and code for fun, I'm just going to post little snippets of advice. 

   I'm working on a team with some newer developers -- just a few years under each belt, versus my decades.  One advantage, as far as this blog goes, is that it lets me spot times when they make newbie-ish mistakes that I've since learned solutions to.  Then I can share those solutions with you!  (And of course my colleagues.)

   The first one is to keep track of work not-yet-done, or klugily done in a way that should be fixed ASAP. 

   On one of the programs we're working on now, there's a part that counts down "3, 2, 1" in some particular (human) language, depending on the "content set" it's using.  (As you may recall, I'm now working for Rosetta Stone.)  We got a bug report that this was always in Spanish, even if the content set it was dealing with was in French, English, etc.  Turns out this wasn't so much a bug per se, as an unimplemented feature -- or rather a chain of unimplemented features. 

   It turns out that the guy who originally wrote the program did not know how to make Part A (which knows all about the content set) tell Part B anything.  So, he hardcoded a bunch of things, such as that Part B should launch Part C (which does the countdown) in Spanish.

   Since he's fairly new, I can't fault him too much for not knowing how to do something.  I had to Google it myself.  I can fault him for not trying to find out, though!  Over on my other blog (personal excellence blog Dare2XL), I have an old post about asking for help.  But that's only part of the story -- the non-coding part, and this is my coding blog.  B-)

   The other part is how to keep track of such unimplemented features, temporary kluges, etc.  How do you avoid releasing something as "done", only to get bug reports due to such things?

   What I've usually done is to mark them with "TODO" or "FIXME" comments.  When I think I've finished, I grep the code, configs, etc. for such tags.  (Use grep, not your eyeballs, even if you're using an editor that syntax-highlights those words.  Grep is much more reliable -- and when combinded with find or ls -R, it's much more certain to find all the relevant files.)

   Some of them are about ideas for future features, so I use a colon, and those I mark as "TODO MAYBE:" or "TODO LATER:", rather than simple "TODO:".  Note how the colon is now not next to TODO, so I can grep for "TODO:", not just "TODO".

   Another way is to use your issue-tracker (such as Bugzilla, Jira, Trac, etc.) to make small tickets for yourself for each such feature.  The effectiveness of this approach depends on many factors, such as your team's policy on making tickets, your tool's complexity, and its filtering ability.  If you can easily make tickets that come quickly to your attention without getting in other people's way, great.  If not, you can still use a private task list, be it in some purpose-built tool, a spreadsheet, a simple text file, or whatever -- just so long as you refer to it often.  (I won't go into tons of advice on how to use a task list; surely there are gazillions of web pages on that.  That said, though, Google "Getting Things Done" for one very good approach.)

   Yet another way is to make sure that the requirement is covered by a test.  (Our group is not doing much developer-level testing right now; I intend to fix that.)  In this case, there could have been a test like "Make sure that a French content set makes the countdown launch in French. Repeat for Spanish."  (Or maybe, to get broader coverage, pick two random languages each time.)

   So now, as always, dear reader, it's your turn!  How do you keep track of unfinished, or klugily finished, work, to avoid releasing it into "the wild"?  Leave a comment.