From: Ruby Quiz Date: 2006-08-31T22:44:59+09:00 Subject: [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...