July Meeting

From DojoWiki

Revision as of 22:05, 25 April 2007; view current revision
←Older revision | Newer revision→
Jump to: navigation, search

This page contains the Ruby example worked out in more detail. Feel free to add other language examples. Also, this is intended to be a collaborative effort, so feel free to add comments or correct code, though please keep the current code up to date.

Contents

The Problem

Write a scheduler that can correctly interpret the following Ruby code:

Schedule.every.day
Schedule.every.day.at Time.parse('13:00') 
Schedule.every.day.at "10:30 am" 
Schedule.every.day.at 10.pm 
Schedule.every(10).days
Schedule.every(15).days.at(3.pm).starting Date.civil(2007, 2, 15)
Schedule.every(2).weeks
Schedule.every.monday.starting_after Date.civil(2007, 3, 3)
Schedule.every.hour
Schedule.twice.a.month.starting Date.civil(2007, 3, 4)
Schedule.twice.a.day

The schedule should be able to indicate the next time an event occurs in the schedule given a date (defaulting to now).

The Solution

The current solution can be found at Scheduler Code and Scheduler Test Code.

The Steps

I decided to start off with the first schedule and work down. Each line I divided into two main steps: create a correct schedule for the type of input indicated and then create a builder that creates the correct schedule. I did this in large part because when doing TDD in real life, one usually creates an object based on how it is to be used first.

I also decided to use a TODO task list, based on the above initial sample set. I hadn't normally been doing formal TDD task lists, but given the (hopeful) joint effort and the potential time between updates, a TODO list would be helpful.

I decided to include ActiveSupport's time additions as they made both the tests and the code cleaner. The cost was that the tests ran a bit slower while loading the library.

Step 1. Daily schedules

A daily schedule should always indicate the next day for an event

This was done in the talk. The test was very simple:

  def test_daily_gives_next_day
    schedule = Schedule.new
    assert_equal Time.local(2007, 3, 1), 
                 schedule.next_after(Time.local(2007, 2, 28))
  end

The definition of next_after was similarly simple. This is typicaly of TDD testing — we expect to generalize the code later.

  def next_after(time)
    time.tomorrow
  end

Schedule.every.day gives a daily schedule

One side effect of testing the next_after method separately is that I can simply test that the created schedule has the correct properties (which is generally easier to test). This can be a problem if the class does not really have properties to examine. In this case I decided that items such as frequency are reasonable properties to expose. I would welcome discussion and alternates on this.

The the test for Schedule.every.day is

  def test_every_day
    schedule = Schedule.every.day
    assert_equal 1.day, schedule.frequency
  end

I put this in a separate class, CreateScheduleTest, to mark the separation of intent.

Step 2. Daily schedules with a specified time

I first realized that we did not specify a default start time for a daily schedule. The most obvious time is midnight.

Make the default start time midnight for a schedule

The test starts with a start time that is not midnight (which is was in the original test):

  def test_daily_sets_time_to_midnight
    schedule = Schedule.new
    assert_equal Time.local(2007, 3, 1), 
                 schedule.next_after(Time.local(2007, 2, 28, 6, 30, 1, 400))
  end

Note that the default for Time.local is midnight as well.

Coding this is simple with the ActiveSupport methods:

  def next_after(time)
    time.tomorrow.''change(:hour => 0)''
  end

Add the ability to start at different times

We now add the ability for the scheduler to start at a different time. First question: what does it mean to start every day at 2pm? What we want is that if today is before 2pm we want the next event to happen today, if it is after 2pm we want the next event to happen tomorrow. To make sequences of next events easier if it the start time is exactly 2pm we want the next event to happen tomorrow as well. This can be written as a series of tests:

  def test_daily_at_3_thirty_when_later
    schedule = Schedule.new(:start_time => 
                              {:hour => 3, :min => 30, :second => 15})
    assert_equal Time.local(2007, 3, 6, 3, 30, 15), 
                 schedule.next_after(Time.local(2007, 3, 5, 10))
  end

The start time hash uses the same format as the change hash, i.e., :hour, :min, and :second. At this time milliseconds is not supported.

I've forgotten at this all the steps I went through to get the code (each time there were bugs), but the final code was as follows:

  attr_reader :frequency
  
  DEFAULT_TIME = 0
  DEFAULT_FREQUENCY = 1.day
  
  def initialize(options = {})
    @time_options = options[:start_time] || {}
    @time_options[:hour] ||= DEFAULT_TIME
    @time_options[:min] ||= DEFAULT_TIME
    @frequency = DEFAULT_FREQUENCY
  end
  
  def next_after(time)
    time.tomorrow.change(@time_options)
  end

Next we need to test when the start time is before the set time:

  def test_daily_at_10_thirty_when_earlier
    schedule = Schedule.new(:start_time => 
                              {:hour => 10, :min => 30})
    assert_equal Time.local(2007, 3, 10, 10, 30), 
                 schedule.next_after(Time.local(2007, 3, 10, 10))
  end

A simple solution is as follows:

  def next_after(time)
    if (options_before?(time)) 
      time = time.tomorrow
    end
    time.change(@time_options)
  end

  private 

  def options_before?(time)
    @time_options[:hour] <= time.hour
  end

This solution doesn't work if the time is later only in minutes or seconds; we add tests for these:

  def test_daily_at_2_thirty_when_later_min
    schedule = Schedule.new(:start_time => 
                              {:hour => 2, :min => 30})
    assert_equal Time.local(2007, 3, 12, 2, 30), 
                 schedule.next_after(Time.local(2007, 3, 11, 2, 31))
  end

the first version I did for the new options_before? method was rather ugly:

  def options_before?(time)
    return false if (@time_options[:hour] > time.hour) 
    return true if (@time_options[:hour] < time.hour) 
    return false if (@time_options[:min] > time.min)
    return true    
  end

and would get worse when I added seconds. I decided to rewrite it as follows:

  def options_before?(time)
    optime = @time_options[:hour].hours + @time_options[:min].minutes
    return optime <= time.seconds_since_midnight
  end

Thus when I add the third test, modifying the code will also be easy:

  def test_daily_at_2_thirty_when_later_sec
    schedule = Schedule.new(:start_time => 
                              {:hour => 2, :min => 30, :sec => 15})
    assert_equal Time.local(2007, 3, 15, 2, 30, 15), 
                 schedule.next_after(Time.local(2007, 3, 14, 2, 30, 16))
  end
  def options_before?(time)
    optime = @time_options[:hour].hours 
      + @time_options[:min].minutes + @time_options[:sec].minutes
    return optime <= time.seconds_since_midnight
  end

Without any other changes, this causes a number of tests to fail. The reason is that I did not initialize @time_options[:sec] when it was not specified. This is easily fixed:

  def initialize(options = {})
    @time_options = options[:start_time] || {}
    @time_options[:hour] ||= DEFAULT_TIME
    @time_options[:min] ||= DEFAULT_TIME
    @time_options[:sec] ||= DEFAULT_TIME
    @frequency = DEFAULT_FREQUENCY
  end

This also shows the value of running as many tests as practical, as it catches odd mistakes like this. ("Practical" means as many as can run while you are willing to wait. Currently the startup for the tests is where any lag lies; currently all tests run in 0.01 seconds.)

Add an equality check

I decided to add an equality check to check the create methods instead of checking properties. It feels cleaner to me, and it more directly checks that the special creation methods match the basic creation methods. I have discussed equality testing a bit at [1]. I create a separate fixture to test ==:

class ScheduleEqualityTest < Test::Unit::TestCase
  
  def setup
    @eq = Schedule.new
  end
  
  def test_eq_is_reflexsive
    assert @eq == @eq
  end
end

Not surprisingly, this passes. I add a test that doesn't pass

  def test_same_initialization_are_eq
    eq2 = Schedule.new
    assert @eq == eq2
    assert eq2 == @eq
  end

I do not test using assert_equal in this case because I do not want to depend on how assert_equal uses the == function. There was a bug in the the equals-hash code tester in the Junit Addons library [2], where it uses assertEquals to test the equals function against null. The assertEquals method, however, never actually did the equivalent of eq1 == nil; instead it did a special check for null. Thus the code that was supposed to be tested was never exercised.

The tests passes with the following simple code

  def ==(arg)
    true
  end

Obviously this is not the final code. Add a distinction with an additional test:

  def test_eq_differs_from_nil
    assert !(@eq == nil)
  end

I then update it to the still inadequate

  def ==(arg)
    nil != arg
  end

which passes.

Now for a more meaty test: checking that the start time matters (I can't check that the frequency matters because I currently cannot change the frequency to anything but one day).

  def test_different_start_times_differ
    neq = Schedule.new(:start_time => 
                              {:hour => 10})
    assert !(@eq == neq)
    assert !(neq == @eq)
  end

The following code makes the tests pass:

  def ==(arg)
    return false unless nil != arg
    return @time_options == arg.time_options
  end

With the accessor given only in a protected form:

  protected
  
  def time_options
    @time_options
  end

There is still one type of test to do. Anyone want to guess?