From: Morton Goldberg Date: 2006-08-30T19:02:42+09:00 Subject: Re: [QUIZ] DayRange (#92) Test::Unit gotcha After looking through some of the other solutions to this quiz, I decided to adapt some of the good ideas I found to improve my own solution. Using Test::Unit to for testing was the first modification I wanted to make. I have never used Test::Unit up to now. I thought it was high time for me to do so. Since changing over to Test::Unit doesn't really change my solution, I didn't plan to repost it after making the change. I changed my mind because I ran into something with Test::Unit that really surprised me. First, the modified code and then some more discussion. require 'test/unit' class DayRange DAY_DIGITS = { 'mon' => 1, 'tue' => 2, 'wed' => 3, 'thu' => 4, 'fri' => 5, 'sat' => 6, 'sun' => 7, 'monday' => 1, 'tuesday' => 2, 'wednesday' => 3, 'thursday' => 4, 'friday' => 5, 'saturday' => 6, 'sunday' => 7, '1' => 1, '2' => 2, '3' => 3, '4' => 4, '5' => 5, '6' => 6, '7' => 7 } SHORT_NAMES = %w[_ Mon Tue Wed Thu Fri Sat Sun].freeze LONG_NAMES = %w[_ Monday Tuesday Wednesday Thursday Friday Saturday Sunday].freeze # Return day range as nicely formatted string. # If @long is true, day names appear in long form; otherwise, they # appear in short form. def to_s names = @long ? LONG_NAMES : SHORT_NAMES result = [] @days.each do |d| case d when Integer result << names[d] when Range result << names[d.first] + "-" + names[d.last] end end result.join(", ") end # Return day range as array of integers. def to_a result = @days.collect do |d| case d when Integer then d when Range then d.to_a end end result.flatten end def initialize(*args) @days = [] @long = false @args = args @args.each do |arg| case arg when Integer bad_arg if arg < 1 || arg > 7 @days << arg when /^(.+)-(.+)$/ begin d1 = DAY_DIGITS[$1.downcase] d2 = DAY_DIGITS[$2.downcase] bad_arg unless d1 && d2 && d1 <= d2 d1.upto(d2) {|d| @days << d} rescue StandardError bad_arg end else d = DAY_DIGITS[arg.downcase] bad_arg unless d @days << d end end @days.uniq! @days.sort! normalize end # Use this to change printing behavior from short day names to long day # names or vice-versa. attr_accessor :long private # Convert @days from an array of digits to normal form where runs of # three or more consecutive digits appear as ranges. def normalize runs = [] first = 0 for k in 1...@days.size unless @days[k] == @days[k - 1].succ runs << [first, k - 1] if k - first > 2 first = k end end runs << [first, k] if k - first > 1 runs.reverse_each do |r| @days[r[0]..r[1]] = @days[r[0]]..@days[r[1]] end end def bad_arg raise(ArgumentError, "Can't create a DayRange from #{@args.inspect}") end end class TestDayRange < Test::Unit::TestCase # All these produce @days == [1..7]. ONE_RANGE = [ %w[mon tue wed thu fri sat sun], %w[monDay tuesday Wednesday Thursday friDAY saturday SUNDAY], %w[mon-fri sat-sun], %w[4-7 1-3], (1..7).to_a.reverse, [4, 7, 6, 5, 4, 1, 2, 1, 2, 3, 3, 7, 6, 5], ] # Both these produce @days == [1..3, 5..7]. TWO_RANGES = [ %w[mon-mon tue-tue wed-wed fri-sun], [1, 2, 'mon-wed', 'friday', 6, 7] ] INVALID_ARGS = [ [1, 2, 'foo'], %w[foo-bar], %w[sat-mon], (0..7).to_a.reverse, (1..8).to_a ] @@one_range = [] @@two_ranges = [] def test_args_helper(args, expected) obj = nil assert_nothing_raised(ArgumentError) {obj = DayRange.new(*args)} assert_equal(expected, obj.instance_variable_get(:@days)) obj end def test_valid_args ONE_RANGE.each do |args| @@one_range << test_args_helper(args, [1..7]) end TWO_RANGES.each do |args| @@two_ranges << test_args_helper(args, [1..3, 5..7]) end puts "test_valid_args -- #{@@one_range.size}, # {@@two_ranges.size}" end def test_bad_args puts "test_bad_args" INVALID_ARGS.each do |args| assert_raise(ArgumentError) {DayRange.new(*args)} end end def test_to_s puts "test_to_s -- #{@@one_range.size}, #{@@two_ranges.size}" @@one_range.each do |obj| assert_equal('Mon-Sun', obj.to_s) end @@two_ranges.each do |obj| assert_equal('Mon-Wed, Fri-Sun', obj.to_s) end end def test_to_a puts "test_to_a -- #{@@one_range.size}, #{@@two_ranges.size}" @@one_range.each do |obj| assert_equal((1..7).to_a, obj.to_a) end @@two_ranges.each do |obj| assert_equal([1, 2, 3, 5, 6, 7], obj.to_a) end end end Loaded suite /Users/mg/Projects/Ruby/Ruby Quiz/Quiz 92/quiz_92 Started test_bad_args .test_to_a -- 0, 0 .test_to_s -- 0, 0 .test_valid_args -- 6, 2 . Finished in 0.010845 seconds. 4 tests, 21 assertions, 0 failures, 0 errors None of the assertions in test_to_s and test_to_a execute. This is because @@one_range and @@two_ranges are both empty when these tests are run. My plan of creating the test objects in test_valid_args and reusing them in test_to_s and test_to_a fails. This happens because the tests are run, not in the order in which they appear in the source code, but in the sort order of their names. How strange! The fix is obvious and I have already applied it: rename test_valid_args to test_args so it runs first. I've posted this because it took me longer to trace down this problem than it took me to write the TestDayRange class. I will consider it worth the effort if the posting helps one other person to avoid this trap. On the other hand, if I'm guilty of belaboring a well-known "feature" of Test::Unit, I apologize. Regards, Morton