[SUMMARY] DayRange (#92)

A couple of submitters mentioned that this problem isn't quite as simple as it
looks like it should be and I agree. When I initially read it, I was convinced
I could come up with a clever iterator call that spit out the output. People
got it down to a few lines, but it's still just not as straightforward as I
expected it to be.

A large number of the submitted solutions included tests this time around. I
think that's because the quiz did a nice job of laying down the ground rules and
this is one of those cases where it's very easy to quickly layout a set of
expected behaviors.

A lot of solutions also added some additional functionality, beyond what the
quiz called for. Many interesting additions were offered including enumeration,
support for Date methods, mixed input, and configurable output. A lot of good
ideas in there.

Below, I want to examine Robin Stocker's solution, which did include a neat
extra feature. Let's begin with the tests:

  require 'test/unit'
  
  class DayRangeTest < Test::Unit::TestCase
  
    def test_english
      tests = {
        [1,2,3,4,5,6,7] => 'Mon-Sun',
        [1,2,3,6,7] => 'Mon-Wed, Sat, Sun',
        [1,3,4,5,6] => 'Mon, Wed-Sat',
        [2,3,4,6,7] => 'Tue-Thu, Sat, Sun',
        [1,3,4,6,7] => 'Mon, Wed, Thu, Sat, Sun',
        [7] => 'Sun',
        [1,7] => 'Mon, Sun',
        %w(Mon Tue Wed) => 'Mon-Wed',
        %w(Frid Saturd Sund) => 'Fri-Sun',
        %w(Monday Wednesday Thursday Friday) => 'Mon, Wed-Fri',
        [1, 'Tuesday', 3] => 'Mon-Wed'
      }
      tests.each do |days, expected|
        assert_equal expected, DayRange.new(days).to_s
      end
    end
  
    # ...

Here we see a set of hand-picked cases being tried for expected results. Most
submitted tests iterated over some cases like this, since it's a pretty easy way
to spot check basic functionality.

Do note the final test case handling mixed input. Robin's code supports that,
as many others did.

Here are some tests for Robin's extra feature, language translation:

    # ...
    
    def test_german
      tests = {
        [1,2,3,4,5,6,7] => 'Mo-So',
        [1,2,3,6,7] => 'Mo-Mi, Sa, So',
        [1,3,4,5,6] => 'Mo, Mi-Sa',
        [2,3,4,6,7] => 'Di-Do, Sa, So',
        [1,3,4,6,7] => 'Mo, Mi, Do, Sa, So',
        [7] => 'So',
        [1,7] => 'Mo, So',
        %w(Mo Di Mi) => 'Mo-Mi',
        %w(Freit Samst Sonnt) => 'Fr-So',
        %w(Montag Mittwoch Donnerstag Freitag) => 'Mo, Mi-Fr',
        [1, 'Dienstag', 3] => 'Mo-Mi'
      }
      tests.each do |days, expected|
        assert_equal expected, DayRangeGerman.new(days).to_s
      end
    end
    
    def test_translation
      eng = %w(Mon Tue Wed Fri)
      assert_equal 'Mo-Mi, Fr',
                   DayRangeGerman.new(DayRange.new(eng).days).to_s
    end
    
    # ...

This time the spot checking is done in German, the other language included in
this solution. You can also see support for translating between languages, in
the second test here.

One last test:

    # ...
    
    def test_should_raise
      assert_raise ArgumentError do
        DayRange.new([1, 8])
      end
    end
  
  end

This time the test ensures that the code does not accept invalid arguments.
Some people chose to spot check several edge cases here as well.

OK, let's get to the solution:

  require 'abbrev'

  class DayRange
  
    def self.use_day_names(week, abbrev_length=3)
      @day_numbers = {}
      @day_abbrevs = {}
      week.abbrev.each do |abbr, day|
        num = week.index(day) + 1
        @day_numbers[abbr] = num
        if abbr.length == abbrev_length
          @day_abbrevs[num] = abbr
        end
      end
    end
  
    use_day_names \
      %w(Monday Tuesday Wednesday Thursday Friday Saturday Sunday)
  
    def day_numbers; self.class.class_eval{ @day_numbers } end
    def day_abbrevs; self.class.class_eval{ @day_abbrevs } end
    
    # ...

The main work horse here is DayRange::use_day_names, which you can see used just
below the definition. This associates seven names with the day indices the
program uses to work.

Array#abbrev is used here so the code can create a lookup table for all possible
abbreviations to the actual numbers. Another lookup table is populated for the
code to use in output Strings and this one accepts a target abbreviation size.

The two instance methods below provide access to the lookup tables. Note that
these two methods could use Object#instance_variable_get as opposed to
Module#class_eval if desired.

Next chunk of code, coming right up:

    # ...
    
    attr_reader :days
    
    def initialize(days)
      @days = days.collect{ |d| day_numbers[d] or d }
      if not (@days - day_abbrevs.keys).empty?
        raise ArgumentError
      end
    end
    
    # ...

Nothing too tricky here. DayRange#initialize handles the mixed input by trying
to find it in the lookup table or defaulting to what was passed. There's also a
check in here to make sure we end up with only days we have a name for. This
handles bounds checking of the input.

Other solutions varied the initialization process a bit. I particularly liked
how Marshall T. Vandergrift allowed for multiple arguments, a single Array, or
even Ranges to be passed with some nice Array#flatten work.

Alright, let's get back to Robin's solution:

    # ...
    
    def to_s
      ranges = []
      number_ranges.each do |range|
        case range[1] - range[0]
        when 0; ranges << day_abbrevs[range[0]]
        when 1; ranges.concat day_abbrevs.values_at(*range)
        else ranges << day_abbrevs.values_at(*range).join('-')
        end
      end
      ranges.join(', ')
    end
    
    def number_ranges
      @days.inject([]) do |l, d|
        if l.last and l.last[1] + 1 == d
          l.last[1] = d
        else
          l << [d, d]
        end
        l
      end
    end
  
  end

This is the heart of the String building process and many solutions landed on
code similar to this. DayRange#number_ranges starts the process by building an
Array of Arrays with the days divided into groups of start and end days. Days
that run in succession are grouped together and lone days appear as both the
start and end. For example, the days 1, 2, 3, and 6 would be divided into `[[1,
3], [6, 6]]`.

DayRange#to_s takes the Array from that process and turns it into the output
String. It just separates the groups by the number of members they have. One
and two day groups just have their days added to an Array used to build up the
output. Longer groups are turned into strings with a hyphen between the first
and last entries. Finally, the Array is joined with commas creating the desired
String result.

Ready to see how much extra work it was to translate this class to German?

  class DayRangeGerman < DayRange
    use_day_names \
      %w(Montag Dienstag Mittwoch Donnerstag Freitag Samstag Sonntag), 2
  end

The only required step is to supply the day names and abbreviation level, as you
can see. It wouldn't be much work to add a whole slew of supported languages to
this solution. Very nice.

My thanks to the many people who came out of the woodwork to show just how
creative you can be. You all made such cool solutions and I hope others will
take some time to browse through them.

Tomorrow, we will attempt to psychoanalyze Ruby's Integer class...

Ruby Quiz wrote:

The two instance methods below provide access to the lookup tables. Note that
these two methods could use Object#instance_variable_get as opposed to
Module#class_eval if desired.

Thanks for the tip with #instance_variable_get, I'm just getting into meta-programming. Exciting stuff :slight_smile:

   Robin