nbirnel/bread-calculator

View on GitHub
lib/bread_calculator.rb

Summary

Maintainability
B
4 hrs
Test Coverage
##
# return reasonably precise version of +number+ depending on it's magnitude

def human_round number, base_precision = 0
  #FIXME there is an algorithm for this. What is it?
  precision = base_precision
  if number < 10
    precision = base_precision + 1
  end
  if number < 1
    precision = base_precision + 2
  end
  f_number = sprintf "%.#{precision}f", number
end

##
# Classes for manipulating bread recipes and baker's percentages

module BreadCalculator

  require 'cgi'

  class Version
    MAJOR = 0 
    MINOR = 5 
    PATCH = 3 

    class << self
      def to_s
        [MAJOR, MINOR, PATCH].join('.')
      end
    end
  end

  VERSION = Version.to_s

  ##
  # This class represents an ingredient in a Recipe

  class Ingredient
    attr_accessor :info, :name, :quantity, :units, :type

    ##
    # Creates a new ingredient +name+, with the optional qualities +info+.
    #
    # +info+ should usually contain <tt>:quantity, :units, :type</tt>.
    # +:type+, in the context of bakers' percentage, would be +:flours+, 
    # +:liquids+, or +:additives+.
    
    def initialize name, info={}
      #@units = 'grams'
      @name = name
      @info = info
      info.each do |k,v| 
        instance_variable_set("@#{k}", v)
      end
    end

    ##
    # Returns a new Ingredient, scaled from current instance by +ratio+
    
    def scale_by ratio, units=self.units
      scaled = Hash.new
      self.info.each do |k, v|
        scaled[k] = v
        scaled[k] = v*ratio  if k == :quantity
      end
      Ingredient.new(self.name, scaled)
    end

    #FIXME refactor scale_by and as_bp 
    
    ##
    # Returns a new unitless Ingredient as a baker's percentage of +bp_100+
    
    def as_bp bp_100
      info = self.info.reject{|k,v| k == :units}

      scaled = Hash.new
      info.each do |k, v|
        scaled[k] = v
        scaled[k] = v / bp_100.to_f  if k == :quantity
      end
      Ingredient.new(self.name, scaled)
    end
    
    ##
    # Print a nice text version of Ingredient
    
    def to_s summary=nil
      q = summary ? "#{human_round(@quantity*100).to_s}%" : human_round(@quantity)
      #FIXME check for existance
      "\t#{q} #{@units} #{@name}\n"
    end

    ##
    # Print ingredient as an html unordered list item
    
    def to_html
      "  <li>#{CGI.escapeHTML(self.to_s.strip.chomp)}</li>\n"
    end

  end

  ## 
  # This class represents a discrete step in a Recipe.

  class Step
    attr_reader :techniques, :ingredients

    ##
    # Creates a new step with the the optional array +techniques+,
    # which consists of  ministep strings, and +Ingredients+.
    #
    # This is intended to read something like:
    # <tt>"Mix:", @flour, @water, "thoroughly."</tt>
    #
    # or:
    #
    # <tt>"Serve forth."</tt>
    
    def initialize *args
      self.techniques = args.flatten
    end

    ##
    # Sets +Step.techniques+ to +args+, and defines +Step.ingredients+
    
    def techniques= args
      @techniques = args
      @ingredients = args.select{|arg| arg.is_a? Ingredient}
    end
    
    ##
    # Print a nice text version of Step
    
    def to_s summary=nil
      out = ''
      self.techniques.each do |t| 
        tmp =  t.is_a?(Ingredient) ? t.to_s(summary) : "#{t.chomp}\n"
        out << tmp
      end
      out << "\n"
      out
    end

    ##
    # Print Step as an html paragraph

    def to_html
      out = "<p>\n"
      self.techniques.each do |t| 
        tmp =  t.is_a?(Ingredient) ? t.to_html : "#{CGI.escapeHTML(t.chomp)}"
        out << tmp
      end
      out << "\n</p>\n"
      out
    end

  end

  ##
  # This class represents a recipe.
  #
  # Runtime-generated methods:
  #
  #     total_flours 
  #     total_liquids
  #     total_additives
  #
  # return totals of their respective types

  class Recipe
    attr_reader :steps, :metadata
    
    ##
    # Creates a new Recipe with hash +metadata+ and array of Steps +steps+
    #
    # +metadata+ is freeform, but most likely should include +:name+.
    # Other likely keys are: 
    # <tt>:prep_time, :total_time, :notes, :history, :serves, :makes,
    # :attribution</tt>.

    def initialize metadata, steps
      @metadata = metadata
      @steps    = steps
      @ingredients = self.ingredients
    end

    ##
    # Returns an array of all Ingredients in Recipe
    
    def ingredients
      a = Array.new
      self.steps.each do |step|
        step.ingredients.each do |ing|
          a << ing
        end
      end
      a
    end

    ##
    # Returns the total weight of Ingredients in Recipe
    
    def weight
      self.ingredients.map{|i| i.quantity}.reduce(:+)
    end

    #FIXME make this a method_missing so we can add new types on the fly
    #RENÉE - 'end.' is weird or no?
    #FIXME how do I get this into rdoc?
     
    [:flours, :liquids, :additives].each do |s|
      define_method("total_#{s}") do
        instance_variable_get("@ingredients").select{|i| i.type == s}.map do |i|
          i.quantity
        end.reduce(:+)
      end
    end

    alias_method 'bakers_percent_100', 'total_flours'

    ##
    # Returns the baker's percentage of a weight
    
    def bakers_percent weight
      weight / bakers_percent_100.to_f
    end

    ##
    # Returns a Formula
    
    def bakers_percent_formula
      ratio = 100.0 / self.total_flours
      self.scale_by ratio
    end
    
    ## 
    # Returns new Recipe scaled by +ratio+

    def scale_by ratio
      new_steps = self.steps.map do |s| 
        step_args = s.techniques.map do |t| 
          t.is_a?(Ingredient) ? t.scale_by(ratio) : t
        end
        Step.new step_args
      end

      Recipe.new self.metadata, new_steps
    end

    ##
    # Return a new recipe of weight and units +args+
    def recipe args=1
      self.summary.recipe args
    end

    ##
    # Returns a Summary
    
    def summary
      new_meta = self.metadata
      [:flours, :liquids, :additives].each do |s|
        new_meta["total_#{s}"] = eval "self.bakers_percent self.total_#{s}"
      end
      
      new_steps = self.steps.map do |s| 
        step_args = s.techniques.map do |t| 
          t.is_a?(Ingredient) ? t.as_bp(self.bakers_percent_100) : t
        end
        Step.new step_args
      end

      Summary.new new_meta, new_steps
    end

    ##
    # Print a nice text version of Recipe
    
    def to_s
      out = ''
      self.metadata.each{|k,v| out << "#{k}: #{v}\n"}
      out << "--------------------\n"
      self.steps.each{|s| out << s.to_s }
      out
    end

    ##
    # Print recipe as html.
    # It is the caller's responsibility to provide appropriate headers, etc.

    def to_html
      #FIXME refactor with to_s
      out = ''
      self.metadata.each{|k,v| out << "<p>\n<b>#{k}</b>: #{v}\n</p>\n"}
      out << "--------------------\n"
      self.steps.each{|s| out << s.to_html }
      out
    end

  end

  ##
  # This class represents a summary of a Recipe - like a Recipe, but:
  #
  # Ingredients will be unitless 
  # and the quantities expressed in baker's percentages,
  #
  # Metadata will include baker's percentages of 
  # flours, liquids, and additives.

  class Summary < Recipe

    def initialize metadata, steps
      super
    end

    def weight
      nil
    end

    def summary
      self
    end

    def to_s
      #FIXME this should be calling super somehow -refactor with Recipe.to_s
      out = ''
      self.metadata.each do |k,v| 
        nv =  k.to_s =~ /^total_/ ? "#{human_round(v*100).to_s}%" : v
        out << "#{k}: #{nv}\n"
      end
      out << "--------------------\n"
      self.steps.each{|s| out << s.to_s(:summary)}
      out
    end

    def to_html
      #FIXME obviously inadequate
      super
    end

    def recipe weight, units='grams'
      #FIXME refactor with Recipe.summary
      
      new_metadata = self.metadata.reject{|k,v| k.to_s =~ /^total_/}

      totals = self.metadata.select{|k,v| k.to_s =~ /^total_/}
      all_totals = totals.values.inject(:+).to_f
      new_bp_100 = weight / all_totals
      
      new_steps = self.steps.map do |s| 
        step_args = s.techniques.map do |t| 
          t.is_a?(Ingredient) ? t.scale_by(new_bp_100, units) : t
        end
        Step.new step_args
      end

      Recipe.new new_metadata, new_steps
      
    end

  end

  ##
  # This class converts a nearly free-form text file to a Recipe
  
  class Parser

    ##
    # Create a new parser for Recipe
    
    def initialize
      @i = 0
      @args = @steps = []
      @steps[0] = BreadCalculator::Step.new

      @in_prelude = true
      @prelude = ''
      @metadata = Hash.new(nil)
    end

    ##
    # Parse text from IO object +input+. It is the caller's responsibility to
    # open and close the +input+ correctly.
    #
    # text recipes consist of a metadata prelude followed by steps.
    #
    # In prelude lines,
    # anything before a colon is considered to be the name of a metadata field;
    # anything after the colon is a value to populate.
    # Lines without colons are continuations of the 'notes' field.
    # I suggest having at least a 'name' field.
    # 
    # A line starting with a hyphen ends the prelude and starts the first step. 
    # 
    # Each step is delimited by one or more blank lines.
    # 
    # Any line in a step starting with a space or a blank is an ingredient,
    # consisting of quantity, units, and the ingredient itself.
    #
    # A brief sample:
    #
    #     name: imaginary bread
    #     notes: This is a silly fake bread recipe
    #     makes: 1 bad loaf
    #     This line will become part of the notes
    #     ---------------------
    #     Mix:
    #       500 g flour
    #       300 g water
    #
    #     Bake at 375°

    def parse input

      while line = input.gets
        new_step              && next if line =~ /(^-)|(^\s*$)/
        preprocess_meta(line) && next if @in_prelude

        @args << preprocess_step(line.chomp)
      end   

      close_step
      # because we made a spurious one to begin with
      @steps.shift
      Recipe.new @metadata, @steps
    end

    private

    def new_step
      @in_prelude = false
      close_step

      @args = []
      @i += 1
      @steps[@i] = BreadCalculator::Step.new
    end

    def close_step
      @steps[@i].techniques = @args
    end

    def preprocess_meta line
      /^((?<key>[^:]+):)?(?<value>.*)/ =~ line
      match = Regexp.last_match
      key = match[:key] ? match[:key].strip.to_sym : :notes
      if @metadata[key]
        @metadata[key] << "\n\t"
      else
        @metadata[key] = ''
      end
      @metadata[key] << match[:value].strip
    end

    def preprocess_step line
      ing_regex = /^\s+((?<quantity>[0-9.]+\s*)(?<units>g)?\s+)?(?<ingredient>.*)/
      ing_regex =~ line ? line_to_ingredient(Regexp.last_match) : line.strip
    end

    def line_to_ingredient match
      h = Hash.new
      liquids   = Regexp.union ['water', 'egg', 'mashed', 'milk']
      additives = Regexp.union ['dry', 'powdered']

      h[:quantity] = match[:quantity].strip.to_f
      h[:units]    = match[:units].strip
      ingredient   = match[:ingredient].strip

      h[:type] = :additives #if it doesn't match anything else

      h[:type] = :flours    if ingredient =~ /meal/
      h[:type] = :liquids   if ingredient =~ liquids
      h[:type] = :additives if ingredient =~ additives

      # These override any other guesses. FIXME refactor?
      h[:type] = :flours    if ingredient =~ /flour/
      h[:type] = :liquids   if ingredient =~ /liquid/
      h[:type] = :additives if ingredient =~ /additive/

      BreadCalculator::Ingredient.new ingredient, h
    end

  end

end