Fwd: DayRange(#92) - My solution

Brian pointed out that I’d missed the spec and was hypenating two day
runs instead of the minimum run called for.

So, I’ve updated my solution to fix this, and keeping with the spirit
of options, I added a :min_span option to set the minimum number of
days to coalesce.

I’ve also made to_s work with the :week_start option as well.

I was going to go ahead and add the ability to specify the :week_start
as a string as well as an integer, but I realized that I really need
to refactor a bit before I do that, so I stopped for now.

---------- Forwarded message ----------
From: Bryan D. [email protected]
Date: Aug 30, 2006 2:45 PM
Subject: Re: [QUIZ] DayRange(#92) - My solution
To: [email protected]

Rick,

Wow, a very detailed and extensive solution…

I did notice one thing, and maybe this was intentional, but when you
pass in 1,2,4,5 you should get “Mon, Tue, Thu, Fri”, not “Mon-Tue,
Thu-Fri”. However, this is just according to the quiz “rules” and is
not necessarily the best way. Maybe an option to specify the minimum
number of consecutive days to constitute a “range” would be a good
addition. I’ll probably add that to my own version …

Regards,
Bryan

On 8/30/06, Rick DeNatale [email protected] wrote:

lots of new features. I guess I fall into the latter camp.
will never span over a week boundary.


Rick DeNatale

My blog on Ruby
http://talklikeaduck.denhaven2.com/


Rick DeNatale

My blog on Ruby
http://talklikeaduck.denhaven2.com/

IPMS/USA Region 12 Coordinator
http://ipmsr12.denhaven2.com/

Visit the Project Mercury Wiki Site
http://www.mercuryspacecraft.com/

Ackkkk!

I just noticed how my attachments came over on the rubyquiz website.
So here’s the code in-line in the message
=== subranges.rb ===
module Enumerable

# Return an array containing the sub-ranges of sorted contents of the 

receiver
# Each element must be comparable, and must respond to succ
def subranges(min_span = 2)
range_start = range_end = nil
subranges = []
self.sort.each do |elem|
if range_start.nil?
range_start = range_end = elem
else
if range_end.succ == elem
range_end = elem
else
subrange = (range_start…range_end)
if subrange.entries.length >= min_span
subranges << subrange
else
subrange.each {|ea| subranges << (ea…ea) }
end
range_start = range_end = elem
end
end
end
unless range_start.nil?
subrange = (range_start…range_end)
if subrange.entries.length >= min_span
subranges << subrange
else
subrange.each {|ea| subranges << (ea…ea)}
end
end
subranges
end

end

=== test_day_range.rb===
require ‘day_range.rb’
require ‘test/unit’

class TestDayRange < Test::Unit::TestCase

EsperantoMap = {"Lundo" => 1, "Lun" => 1, "Mardo" => 2, "Mar" => 2,

“Merkredo” => 3, “Mer” => 3,
“Jhaudo” => 4, “Jha” => 4, “Vendredo” => 5, “Ven” => 5, “Sabato” =>
6, “Sab” => 6,
“Dimancho” => 7, “Dim” => 7}

EsperantoNames = ["Lun", "Mar", "Mer", "Jha", "Ven", "Sab","Dim"]

GermanMap = { "Montag" => 1, "Mon" => 1, "Dienstag" => 2, "Die" => 2,

“Mittwoch” => 3, “Mitt” => 3,
“Donnerstag” => 4, “Don” => 4, “Freitag” => 5, “Frei” => 5,
“Samstag” => 6, “Sam” => 6,
“Sonntag” => 7, “Sonn” => 7 }
GermanNames = [“Mon”, “Die”, “Mitt”, “Don”, “Frei”, “Sam”, “Sonn”]

def test_equal_1
	dr1 =  DayRange.new(1,2,4,5)
	dr2 =  DayRange.new(1,2,4,5)
	dr3 = DayRange.new(2,5,4,1)
	assert_equal(dr1,dr2)
	assert_equal(dr1,dr3)
end

def test_weekstart_1
	dr1 = DayRange.new('Mon', 'Tuesday', 'Thursday', 'Friday', 'Sat',

:week_start => 2)
assert_equal(“Tue, Thu-Sat, Mon”,dr1.to_s)
end

def test_equal_2
	dr1 =  DayRange.new(1,2,4,5)
	dr2 = DayRange.new('Monday', 'Tuesday', 'Thursday', 'Friday')
	assert_equal(dr1,dr2)
end

def test_to_s_numbers
	dr =  DayRange.new(1,2,4,5)
	assert_equal("Mon-Tue, Thu-Fri",dr.to_s(:min_span => 2))
	assert_equal("Mon, Tue, Thu, Fri",dr.to_s)
	dr =  DayRange.new(1,2,3, 5,6)
	assert_equal("Mon-Wed, Fri-Sat",dr.to_s(:min_span => 2))
	assert_equal("Mon-Wed, Fri, Sat",dr.to_s)
end

def test_to_s_names
	dr = DayRange.new('Monday', 'Tuesday', 'Thursday', 'Friday')
	assert_equal("Mon, Tue, Thu, Fri",dr.to_s)
	assert_equal("Mon-Tue, Thu-Fri",dr.to_s(:min_span => 2))
end

def test_to_s_options
	dr = DayRange.new('Monday', 'Tuesday', 'Thursday', 'Friday')
	assert_equal("Lun, Mar, Jeu, Ven", dr.to_s(:language => :French))
	assert_equal("Lun-Mar, Jeu-Ven", dr.to_s(:language => :French,

:min_span => 2))
assert_equal(“Mercury-Venus, Mars-Saturn”,
dr.to_s(:day_names => %w[Mercury Venus Earth Mars Saturn
Jupiter Uranus],
:min_span => 2)
)

        dr = DayRange.new(1, 2, 3, 6, 7)
	assert_equal("Mon-Wed, Sat, Sun", dr.to_s)
	assert_equal("Tue, Wed, Sat-Mon", dr.to_s(:week_start => 2))
	assert_equal("Wed, Sat-Tue",dr.to_s(:week_start => 3))
	assert_equal("Sat-Wed", dr.to_s(:week_start => 4))
	assert_equal("Sat-Wed", dr.to_s(:week_start => 5))
	assert_equal("Sat-Wed", dr.to_s(:week_start => 6))
	assert_equal("Sun-Wed, Sat", dr.to_s(:week_start => 7))
end
def test_translate_to_french
	dr =  DayRange.new(1,2,4,5)
	assert_equal("Lun, Mar, Jeu, Ven",dr.to_s(:language => 'French'))
	assert_equal("Lun-Mar, Jeu-Ven",dr.to_s(:language => 'French',

:min_span => 2))
end

def test_new_french
	dr1F = DayRange.new(1,2,4,5, :language => 'French')
	dr1 =  DayRange.new(1,2,4,5)
	assert_equal(dr1, dr1F)
	assert_equal("Lun-Mar, Jeu-Ven",dr1F.to_s(:min_span => 2))
	assert_equal("Mon-Tue, Thu-Fri",dr1F.to_s(:language => 'English',

:min_span => 2))
assert_equal(“Lun, Mar, Jeu, Ven”,dr1F.to_s)
assert_equal(“Mon, Tue, Thu, Fri”,dr1F.to_s(:language => ‘English’))
end

def test_new_esperanto
	dr1Esp = DayRange.new(1,2,4,5, :day_map => EsperantoMap)
	dr1 =  DayRange.new(1,2,4,5)
	assert_equal(dr1, dr1Esp)
	assert_equal("Lun-Mar, Jha-Ven", dr1Esp.to_s(:min_span => 2))
end

def test_bad_days
	assert_raise(ArgumentError) {DayRange.new(1,2,8)}
	assert_raise(ArgumentError) {DayRange.new(1, :day_map => {'Mon' =>

1, “Tue” => 9})}
end

def test_add_german
	DayRange.remove_language(:German)
	DayRange.add_language(:German, GermanMap, GermanNames)
	assert_equal("Die, Don, Sam, Sonn", DayRange.new(2,4,6,7, :language

=> ‘German’).to_s)
assert_equal(“Die, Don, Sam-Sonn”, DayRange.new(2,4,6,7,
:language => ‘German’).to_s(:min_span => 2))

	DayRange.remove_language(:German)
	DayRange.add_language(:German, GermanMap)
	assert_equal("Die, Don, Sam, Sonn", DayRange.new(2,4,6,7, :language

=> ‘German’).to_s)
assert_equal(“Die, Don, Sam-Sonn”, DayRange.new(2,4,6,7,
:language => ‘German’).to_s(:min_span => 2))
end

def test_each_name
	dr = DayRange.new(1,2, 5, 6)
	expected = ['Mon', 'Tue', 'Fri', 'Sat']
	dr.each_name { |name| assert_equal(expected.shift, name)}
	assert(expected.empty?, "Missing results #{expected.inspect}")
	expected = ['Doc', 'Grumpy', 'Bashful', 'Sleepy']
	dwarves = ['Doc', 'Grumpy', 'Happy', 'Sneezy','Bashful', 'Sleepy', 

‘Dopey’]
dr.each_name(:day_names => dwarves) { |name|
assert_equal(expected.shift, name)}
assert(expected.empty?, “Missing results #{expected.inspect}”)
expected = [‘Lun’, ‘Mar’, ‘Ven’, ‘Sam’]
dr.each_name(:language => ‘French’) { |name|
assert_equal(expected.shift, name)}
assert(expected.empty?, “Missing results #{expected.inspect}”)
end
end

=== day_range.rb===

This class was written as an answer to RubyQuiz92.

The DayRange.new method takes one or more day specifications as

either integers or natural language

strings representing day names. An instance will respond to to_s

with a string representing

the list of days with consecutive days collapsed to a form like

‘Mon-Fri’

Several methods take “Rails-style” options, one or more associations

after any normal parameters.

The keys of these associations can be Strings or symbols which will

be converted using to_sym.

Features not called for in the quiz include:

* A number for the start of the week may be specified. This will

affect the output of

to_s. For example, :week_start => 7, indicates that the week

starts on Sunday, and

DayRange.new(‘Sat’, ‘Sun’, ‘Mon’, :week_start => 7).to_s =>

“Sun-Mon, Sat”

* Support is provided for languages other than English, French is

built in, but additional

languages can be added, either on the new call, or by a class

method DayRange.add_language

* DayRanges are enumerable and produce the numbers of the day they

contain, in Monday-Sunday

order.

* Two Dayranges are == if they contain the same days

Author: Rick DeNatale http://talklikeaduck.denhaven2.com

Test cases are in the file testdayrange.rb

The code which does most of the work in detecting sub-ranges is in

the file subranges.rb

This adds a method to Enumerable which produces an array of ranges

which cover the same contents

as the Enumeration.

require ‘subranges’

class DayRange

include Enumerable

# StringSymHash extends Hash so that symbol and string keys are

equivalent a la Rails
# Normally I don’t like implementing things like this via sub-classing
but…
class StringSymHash < Hash

	def [](key)
		super(key.to_sym)
	end

	def []=(key,value)
		super(key.to_sym, value)
	end

	def StringSymHash.[](hash)
		ssh = StringSymHash.new
		hash.each { |k, v| ssh[k] = v}
		ssh
	end
end

# maps and names for English and French
@@day_maps = StringSymHash[ :English =>  {
                                         'Monday' => 1, 'Mon' =>

1, ‘Tuesday’ => 2, ‘Tue’ => 2,
‘Wednesday’ => 3, ‘Wed’ => 3,
‘Thursday’ => 4, ‘Thu’ => 4,
‘Friday’ => 5, ‘Fri’ => 5, ‘Saturday’
=> 6, ‘Sat’ => 6,
‘Sunday’ => 7, ‘Sun’ => 7 },
:French => {
‘Lundi’ => 1, ‘Lun’ => 1,
‘Mardi’ => 2, ‘Mar’ => 2,
‘Mercredi’ => 3, ‘Mer’ => 3, ‘Jeudi’ =>
4, ‘Jeu’ => 4,
‘Vendredi’ => 5, ‘Ven’ => 5, ‘Samedi’
=> 6, ‘Sam’ => 6,
‘Dimanche’ => 7, ‘Dim’ => 7 } ]
@@day_names = StringSymHash[
:English => [nil, ‘Mon’, ‘Tue’, ‘Wed’, ‘Thu’, ‘Fri’, ‘Sat’, ‘Sun’],
:French => [nil, ‘Lun’, ‘Mar’, ‘Mer’, ‘Jeu’, ‘Ven’, ‘Sam’, ‘Dim’],
]

# Add a language to those supported by the DayRange class
#
# :call-seq:
#    DayRange.add_language(lang_name, day_map[, day_names])
#
# The <em>lang_name_ parameter</em> is the name of the language. It

will be internally converted
# to a symbol. So, for example, if you have:
#
# DayRange.add_language(‘Esperanto’, …)
#
# then one could ask for a DayRange in Esperanto with:
#
# DayRange.new(1, 3, :Language => :Esperanto)
#
# The day_map parameter should be a Hash which maps day
names to integers in
# the range (1…7). More than one name may map to a particular
day_number.
#
# The day_names parameter must be duck-typeable to a 7-element
array or Strings, with the
# first element containing the name which will be used for Monday for
output (e.g. for to_s),
# and the last for Sunday.
#
# If day_names is omitted, then it will be constructed by finding a
name for each day_number
# as least as short as any other name which maps to that day_number
in day_map
def DayRange.add_language(lang_name, day_map, day_names = nil)
#HACK - if user supplied day_names pre-pend an element so that we can
use
# pseudo 1-origin indexing

	day_names = day_names.dup.unshift('') if day_names
	@@day_names[lang_name.to_s] = validated_day_names(day_names ||

day_names_from_day_map(day_map))
@@day_maps[lang_name.to_s] = day_map.dup
end

# Remove the language _lang_name_
# Do nothing silently if _lang_name_ is not present
#
# :call-seq:
#    DayRange.remove_language(lang_name)
#
def DayRange.remove_language(lang_name)
	@@day_names.delete(lang_name)
	@@day_maps.delete(lang_name)
end

def DayRange.day_names_from_day_map(day_map) #:nodoc:
	# set each days name to the shortest name
	# in the name mapping
	# puts("Debug- DayRange.day_names_from_day_map(#{day_map})")
	day_names = Array.new(8)
	day_map.each do |name ,number|
		current_name = day_names[number] || day_names[number] = name
		day_names[number] = name if name.length < current_name.length
	end
	validated_day_names(day_names)
end

def DayRange.validated_day_names(day_names) #:nodoc:
	(1..7).each do |i|
		check_arg(day_names[i], "No name for day number #{i}")
	end
	day_names
end

def DayRange.check_arg(assertion, msg) # :nodoc:
	raise ArgumentError.new( msg) unless assertion
end

def DayRange.day_names_from_options(options, day_map) # :nodoc:
	# puts "Debug- 

DayRange.day_names_from_options(options=#{options.inspect},"
# puts " day_map=#{day_map.inspect})"
return options[:day_names] if options.key?(:day_names)
return DayRange.day_names_from_day_map(day_map)
end

def DayRange.language_from_options(options) # :nodoc:
	get_option(:language, options, :English)
end

def DayRange.get_option(option, options, default) # :nodoc:
	options.key?(option) ? options[option] : default
end

# Returns a new DayRange (which contains one or more days of the week)
#
# :call-seq:
#     DayRange.new(day* [, options])
#
# <em>day</em> arguments can be either numbers in the range
# (1..7) or names in the <em>day_mapping</em> (see <b>:day_mapping</b> 

option).
#
# Options:
#
# [:language => symbol] Specifies the language to be used to
# interpret the _day_s which are Strings, and for the default options
for output via DayName#to_s
# The possible values for the symbol are :English, and :French,
# additional languages can be added via the DayRange.addLanguage
# method. If this option is not specified, :English will be used.
#
# [:day__map => hash ] The value hash should be a hash which maps
the names
# of days to the number of the day, with 1 being the first
# day of the week (normally Monday), up to 7 for the last day
# of the week (normally Sunday).
# More than one name may map to the same day. If not specified,
# the day_mapping for the selected language is used.
#
# [:day_names => array] The value array must be duck-typeable to
a 7-element array.
# The elements are the names of the days to be used by default for
# output (e.g. with DayRange#to_s. If not specified, then the
day_names for the selected
# language will be used, unless :day_map is specified in which case
# :day_names will be computed from one of the sortest names in the
map for each
# day.
#
# [:week_start => int] The value int must be in the range (1, 7).
# It is used to shift the start of the week. For example to create a
# DayRange for a week which starts on Sunday rather than Monday,
# specify a week_start of 7. Although it is also possible to
# achieve the same effect by changing the numbers in day_mapping,
# using week_start allows the same day_mapping to be used for weeks
# starting on different days.
#
# [:min_span => int] The value int indicates the minimum span of
days which will
# be collapsed into hyphenated form. The default is 3, as specified
by the Quiz spec
# I missed this the first time.
def initialize( *days ) #:doc:
options = extract_options_from_args!(days)
# puts “Debug: options = #{options.inspect}”
@day_map = day_map_from_options(options)
@day_map.each do |name, number|
DayRange.check_arg((1…7) === number,
“‘#{number}’ is not an
acceptable day for #{name.to_s}.”)
end
@min_span = DayRange.get_option(:min_span, options, 3)
@language = DayRange.language_from_options(options)
@day_names = DayRange.day_names_from_options(options,@day_map)
@week_start = week_start_from_options(options, @day_map)
@day_numbers = days.map do | day |
number = @day_map[day] || day
DayRange.check_arg((1…7) === number, “‘#{number.inspect}’ is not
an acceptable day.”)
number
end
@day_numbers.sort!
end

# Return an array of subranges of @day_numbers adjusted for the 

week_start
def adjusted_ranges(min_span, week_start)
(week_start == 1 ? @day_numbers : @day_numbers.map { |elem|
ws_adj(elem,week_start) }).subranges(min_span)
end

# Two DayRanges are == if they contain the same day numbers
def ==(other)
	false unless other.kind_of? DayRange
	self.to_a == other.to_a
end


# Call _block_ once for each day number in _day_range_ passing the

day number to the block.
# The order should be the same regardless of week start, i.e. Monday
should always come first
# then Tuesday, etc.
#
# :call-seq:
# day_range.each {|day_number| block } → day_range
def each()
@day_numbers.each { | elem | yield elem }
end

# Convert _day_range_ to an array, elements will be in order so that

Monday, if it is the range
# will be first then Tuesday, etc. i.e. the effect of weekstart will
be removed
# :call-seq:
# day_range.to_a
def to_a
@day_numbers.dup
end

# Call _block_ once for each day name in _day_range_, passing that

name to the block
#
# :call-seq:
# day_range.each_name [(options)] { |day_name| block } →
day_range
#
# Options
#
# Options are specified Rails style, as one or more associations at
the end of the argument
# list.
#
# [:language => symbol] Specifies the language to be used for the
names
# The possible values for the symbol are :English, and :French,
# additional languages can be added via the DayRange.addLanguage
# method. If this option is not specified, :English will be used.
#
# [:day_names => array] The array must be 7-element array.
# The elements are the names of the days to be used by default for
# output via to_s. If not specified, then the day_name for the
selected
# language will be used.
#
def each_name(options={})
names = get_names_override(options)
to_a.each { | day_number | yield names[day_number] }
end

def get_names_override(options)
	return options[:day_names].dup.unshift('') if options.key?(:day_names)
	language = options[:language]
	return @@day_names[language] if language
	@day_names
end



# Returns a string representing the DayRange, Options can be specified.
#
# :call-seq:
#     day_range.to_s [(options)]
#
# Options:
#
# [*:language* => symbol] Specifies the language to be used for output
# The possible values for _symbol_ are :English, and :French,
# additional languages can be added via the DayRange.addLanguage
# method. If this option is not specified, the language used when the 

DayRange
# was created will be used.
#
# [:day_names => array] The array must be duck-typeable to a
7-element array.
# The elements are the names of the days to be used by default for
# output via to_s. If not specified, then the day_names for the
selected
# language will be used.
#
# [:min_span => int] The value int indicates the minimum span of
days which will
# be collapsed into hyphenated form. The default is 3, as specified
by the Quiz spec
#
# [:week_start => int] The value int must be in the range (1, 7).
#
def to_s(options={})
names = get_names_override(options)
#puts “Debug: @day_names=#{@day_names.inspect}, names=#{names}”
min_span = DayRange.get_option(:min_span, options, @min_span)
week_start = DayRange.get_option(:week_start, options, @week_start)

	result = ""
	adjusted_ranges(min_span,week_start).map {|range|
		range.first == range.last ?
                           "#{names[ws_unadj(range.first, 

week_start)]}" :
“#{names[ws_unadj(range.first,
week_start)]}-#{names[ws_unadj(range.last,week_start)]}”
}.join(", ")
end

private
    # convert a number where 1 = Mon.. 7 = Sunday to the equivalent
# when the week starts on day number
def ws_adj(number, week_start)
	((number - week_start) % 7) + 1
end

# convert a number back to the original form
def ws_unadj(number, week_start)
	((number + week_start + 5) % 7) + 1
end

def extract_options_from_args!(args)
	#puts "Debug - extract_options_from_args!(#{args.inspect})"
	#puts "       #{args.last.class}"
	StringSymHash[args.last.kind_of?(Hash) ? args.pop : {}]
end

def day_map_from_options(options)
	#puts "Debug: language=#{DayRange.language_from_options(options)}"
        #puts " 

map=#{@@day_maps[DayRange.language_from_options(options)]}"
DayRange.get_option(:day_map, options,
@@day_maps[DayRange.language_from_options(options)])
end

def week_start_from_options(options, day_map)
	week_start = DayRange.get_option(:week_start, options, 1)
	week_start = day_map[week_start] || week_start
	DayRange.check_arg((1..7) === week_start,":week_start must be in the

range (1…7)")
week_start
end

end


Rick DeNatale

My blog on Ruby
http://talklikeaduck.denhaven2.com/