From: "Eric I." Date: 2007-10-22T01:05:06+09:00 Subject: Solution to Time Window (#144) Below you'll find my solution to the quiz. I approached it a relatively standard object-oriented fashion. For example, TimeSpecifier acts as an abstract base class for Day and HourMinute to bring together their commonalities. And I did use some of that good ol' Ruby duck typing so that a TimeRange can be used as a TimeSpecifier. Eric ---- Are you interested in on-site Ruby training that uses well-designed, real-world, hands-on exercises? http://LearnRuby.com ==== # This is a solution to Ruby Quiz #144 (see http://www.rubyquiz.com/) # by LearnRuby.com and released under the Creative Commons # Attribution-Share Alike 3.0 United States License. This source code can # also be found at: # http://learnruby.com/examples/ruby-quiz-144.shtml # A TimeWindow is a specification for a time window. It is specified # by a string, and an instance of Time can be checked to see if it's # included in the window. The specification string is is best # documented by quoting the Ruby Quiz #144 description: # # 0700-0900 # every day between these times # Sat Sun # all day Sat and Sun, no other times # Sat Sun 0700-0900 # 0700-0900 on Sat and Sun only # Mon-Fri 0700-0900 # 0700-0900 on Monday to Friday only # Mon-Fri 0700-0900; Sat Sun # ditto plus all day Sat and Sun # Fri-Mon 0700-0900 # 0700-0900 on Fri Sat Sun Mon # Sat 0700-0800; Sun 0800-0900 # 0700-0800 on Sat, plus 0800-0900 on Sun class TimeWindow # Represents a time range defined by a start and end TimeSpecifier. class TimeRange def initialize(start_t, end_t, include_end, allow_reverse_range = false) raise "mismatched time specifiers in range (%s and %s)" % [start_t, end_t] unless start_t.class == end_t.class raise "reverse range not allowed \"%s-%s\"" % [start_t, end_t] if start_t >= end_t && !allow_reverse_range @start_t, @end_t, @include_end = start_t, end_t, include_end end # Equality is defined as a TimeSpecifier on the RHS being in the # this range. def ==(time_spec) # do either a < or a <= when comparing the end of the range # depending on value of @include_end end_comparison = @include_end ? :<= : :< # NOTE: the call to the send method below is used to call the # method in end_comparison if @start_t < @end_t time_spec >= @start_t && time_spec.send(end_comparison, @end_t) else # a reverse range, such as "Fri-Mon", needs an || time_spec >= @start_t || time_spec.send(end_comparison, @end_t) end end def to_s "%s-%s" % [@start_t, @end_t] end end # This is an abstract base class for time specifiers, such as a day # of the week or a time of day. class TimeSpecifier include Comparable def <=>(other) raise "incompatible comparison (%s and %s)" % [self, other] unless self.class == other.class @specifier <=> other.specifier end protected attr_reader :specifier # Given an "item" regular expression returns a hash of two regular # expressions. One matches an individual item and the other a # range of items. Both returned regular expressions use parens, # so the individual items can be extraced from a match. def self.build_regexps(regexp) individual_re = Regexp.new "^(%s)" % regexp range_re = Regexp.new "^(%s)\-(%s)" % [regexp, regexp] { :individual => individual_re, :range => range_re } end # Attempts to match str with the two regexps passed in. regexps # is a hash that contains two regular expressions, one that # matches a single TimeSpecifier and one that matches a range of # TimeSpecifiers. If there's a match then it returns either an # instance of klass or an instance of a TimeRange of klass (and # str is destructively modified to remove the matched text from # its beginning). If there isn't a match, then nil is returned. # include_end determines whether the end specification of the # range is included in the range (e.g., if the specifier is # "Mon-Fri" whether or not Fri is included). allow_reverse_range # determines whether a range in which the start is after the end # is allowed, as in "Fri-Mon"; this might be alright for days of # the week but not for times. def self.super_parse(str, klass, regexps, include_end, allow_reverse_range) # first try a range match if match_data = regexps[:range].match(str) consume_front(str, match_data[0].size) TimeRange.new(klass.new_from_str(match_data[1]), klass.new_from_str(match_data[2]), include_end, allow_reverse_range) # second try individual match elsif match_data = regexps[:individual].match(str) consume_front(str, match_data[0].size) klass.new_from_str(match_data[1]) else nil end end # Consumes size characters from the front of str along with any # remaining whitespace at the front. This modifies the actual # string. def self.consume_front(str, size) str[0..size] = '' str.lstrip! end end # Time specifier for a day of the week. class Day < TimeSpecifier Days = %w(Sun Mon Tue Wed Thu Fri Sat) @@regexps = TimeSpecifier.build_regexps(/[A-Za-z]{3}/) def initialize(day) raise "illegal day \"#{day}\"" unless (0...Days.size) === day @specifier = day end def to_s Days[@specifier] end def self.new_from_str(str) day = Days.index(str) raise "illegal day \"#{day_str}\"" if day.nil? new(day) end def self.parse(str) super_parse(str, Day, @@regexps, true, true) end end # Time specifier for a specific time of the day (i.e., hour and minute). class HourMinute < TimeSpecifier @@regexps = TimeSpecifier.build_regexps(/\d{4}/) def initialize(hour_minute) hour = hour_minute / 100 minute = hour_minute % 100 raise "illegal time \"#{hour_minute}\"" unless (0..23) === hour && (0..59) === minute @specifier = hour_minute end def to_s "%04d" % @specifier end def self.new_from_str(str) new str.to_i end def self.parse(str) super_parse(str, HourMinute, @@regexps, false, false) end end # Creates a TimeWindow by parsing a string specifying some combination # of day and hour-minutes, possibly in ranges. def initialize(str) # time_frame is a Day, HourMinute, or TimeRangeof either; it is # set here so when it's sent inside the block, it won't be scoped # to the block time_frame = nil @periods = [] str.split(/ *; */).each do |period_str| # frame set is a hash where the keys are either the class Day or # HourMinute and the associated values are all time specifiers # for that class. The default value is the empty array. period = Hash.new { |h, k| h[k] = [] } # process each time specifier in period_str by sequentially # processing andconsuming the beginning of the string until period_str.empty? # set frame_type and time_frame based on the first matching # parse frame_type = [Day, HourMinute].find { |specifier| time_frame = specifier.parse(period_str) } raise "illegal window specifier \"#{period_str}\"." if time_frame.nil? period[frame_type] << time_frame end @periods << period end end # Returns true if the TimeWindow includes the passed in time, false # otherwise. def include?(time) d = Day.new(time.wday) hm = HourMinute.new(time.hour * 100 + time.min) # see if any one period matches the time or if there are no periods @periods.empty? || @periods.any? { |period| # a period matches if either there is no day specification or # one day specification matches, and if either there is no # hour-minute specification or one such specification matches (period[Day].empty? || period[Day].any? { |day_period| day_period == d }) && (period[HourMinute].empty? || period[HourMinute].any? { |hm_period| hm_period == hm }) } end def to_s @periods.map { |period| (period[Day] + period[HourMinute]).map { |time_spec| time_spec.to_s }.join(' ') }.join(' ; ') end end