[The following is a true story. The feature has been changed to protect the unannounced, but the code and test progression is a near verbatim account of a coding session I had recently.]
Say you are testing a timeline. Specifically, you are testing sliding a timeline item around on the timeline. An item can be moved anywhere along the timeline, but moving an item before the origin or off the end of the timeline is not allowed.
The timeline has several different timescales it can use:
You need to write a helper method that manipulates an item on the timeline using its user interface; in other words, to simulate interacting with the timeline the way the user will. This seems relatively simple for the linear case, and not too much harder for the logarithmic case, but the fisheye case sounds like it may require some complicated math -- essentially reproducing the logic the timeline uses to decide how to render itself. That's a scary thought, so let's avoid it as long as we can.
The first step is to create a list of test cases:
Next let's define some terms and invent some functionality that we'll pretend already exists in our automation library:
With that in hand we can get started. Let's start with the simplest case: dragging an item along a linearly scaled timeline. Neither moving right nor moving left seems significantly simpler nor more complicated than the other, so let's arbitrarily decide to start with the former.
(I'll use C#-ish psuedocode throughout. You should be able to translate this into your language of choice without much trouble. I won't explicitly show verification of results, but you should always verify every last thing you can think of.)
Create a timeline // Defaults to length 1000.
Add an item at time 100
Add an item at time 500
Delete the active timeline
This is a straightforward test. If we assume that our timeline displays itself such that one logical pixel is the same as one time point (e.g., time 100 is 100 pixels from the timeline's origin), the implementation to make this work is straightforward as well:
adorner = Get the adorner for item #itemIndex
originPoint = Get the absolute location of the timeline's origin
startPoint = adorner.GetClickablePoint
destinationPoint = newTimeValue + originPoint
Simple stuff. What's more, it works for several of the other linearly scaled tests as well:
Whether we need any changes to handle the illegal cases depends on how the timeline handles out of range items. If it silently stops dragging timeline items when they hit either end of the timeline, we just need to make our verification code handle those cases. If it pops a message box or otherwise throws an error for these cases, however, we may decide to write specific test cases for these edge conditions:
// Somewhere amongst our timeline test cases:
if (the timeline error dialog is visible)
Log a pass
Dismiss the timeline error dialog
Log a fail
This leaves our automation system free to throw an exception in these cases:
// Back in our unit tests:
if ((newTimeValue < 0.0) or (timeline.Range < newTimeValue))
throw new ArgumentOutOfRangeException("newTimeValue", newTimeValue
, "The new time value must be within the timeline's limits.")
(Of course, we could also decide our automation library should look for and dismiss the error dialog, in which case we would neither do the check nor throw an exception. This would work if we do verification inline in the automation library methods, but not if verification is done external to the automation library -- that verification needs to know if the error dialog appeared, but it wouldn't have any way to get this information.)
Since GetAdornerForTimelineItem throws if the specified timeline item doesn't exist, we don't need to explicitly check whether the requested timeline item actually exists; instead we just pass the index along and let GetAdornersForTimelineItem throw when necessary.
That's all the linearly scaled cases, so let's move on to the logarithmically scaled cases. Calculating the drag point is a bit more complicated than it was for the linear case, but not too much more:
if (timeline is linearly scaled)
destinationPoint = CalculateXCoordinateForLogarithmicValue(newTimeValue) + originPoint
We check the timeline's type and either do the simple calculation we did before or call out to a helper function we get someone else to write. Simple! <g/>
This would work (even if we have to write the helper ourselves), and we could do much the same thing for fisheye scaled timelines. We would now have three separate algorithms to maintain, however, and some of them are likely to be complicated. If additional timeline types are added in the future, or zooming, or any number of other things, the code gets ever more complicated.
Not to fear; there's a better way. What if we just dragged the timeline item until it reached the desired time value? A naïve implementation might look like this:
while (the timeline item's current time value < the desired time value)
while (the desired time value < the timeline item's current time value)
If the timeline item is currently to the left of the desired value, drag it right one pixel; if it's to the right, drag left. Repeat until it reaches the desired value. Moving a pixel at a time we're bound to hit the desired value spot on.
Try it on our existing tests; most pass, but MoveTimelineItemPastOtherTimelineItem fails. Why? Run it again, watching carefully. Oh…we're doing a series of drags -- in other words, a mouse down, mouse move, and mouse up each time. At one point we move the timeline item directly over the other timeline item, so that both timeline items now have identical clickable points. The next mouse down grabs the second timeline item, not the one we want.
Is this the correct behavior? The spec should say, and if not a three-way between us, our dev, and our PM is in order. In this case we find that this behavior was specifically left unspecified. Thus the first item might get selected, or perhaps the second, and what happens this time isn't necessarily what happens next time.
Okey dokey. A series of mouse drags isn't going to work, then; instead we need to do one long drag so that we never release the timeline item:
Mouse.MoveTo(startPoint + 1)
while (the desired time value < the timeline' items current time value)
Mouse.MoveTo(startPoint - 1)
With that change all our tests are passing again. Add a new test for a logarithmically scaled timeline:
Toggle the active timeline to be logarithmically scaled
That passes, so try a fisheye timeline. That passes too.
So we're set, right? Yes and no. Our algorithm does work, but it's inefficient. Moving timeline items any significant distance will take forever moving just a pixel at a time. Let's see what happens with a really long timeline:
Stretch the active timeline to be ten times as long
ActiveTimeline.MoveTimelineItem(0, (the active timeline's length - 10))
That took a while!
Another incentive to enhance our algorithm is our wish to test like the user. Users won't generally inch along; they'll move in large jumps until they get close to the desired value, then scale down to more and more exact movements. Let's see if we can't duplicate that behavior.
We'll start by dragging a large distance rather than the one pixel we drag now. What should this new distance be? We could choose some arbitrary number (50, say, or 10), but this number is likely to be too small for long timelines and too long for short timelines. Basing the drag distance on the timeline's length should generally give us a decent starting point:
dragIncrement = the timeline's range / 10
Mouse.MoveTo(startPoint + dragIncrement)
Mouse.MoveTo(startPoint - dragIncrement)
This will reach the desired location much faster, but in most cases it most likely won't be nearly accurate enough. Run the tests anyway, just to be sure. One or two work because the timeline item happens to be placed just right, but most of the tests fail because the timeline item was dragged past the desired time value.
One solution would be to recognize that we're getting close to the desired time value and reduce our drag increment appropriately, making it progressively smaller the closer we get to the desired time value. To do that, however, we would have to calculate how much further we have to go -- exactly the task we are trying to avoid.
Another option would be to recognize that we've moved past our destination and start moving in the opposite direction. If we go too far again, we reduce the drag increment and drag back in the first direction. If we yo-yo like this long enough we should eventually zero in on our target time value. This sounds like it should work.
We'll do this in stages. First we'll refactor our existing code and replace the dual MoveTo loops with a single loop that uses a drag direction. Aiding us in this endeavor is the Sign method, which returns -1 if its argument is negative, 1 if its argument is positive, and 0 if its argument if zero.
currentDragDirection = Sign(the desired time value - the timeline item's current time value)
if (0 == currentDragDirection)
// We're already at the desired time value.
while (the timeline item's current time value != the desired time value)
Mouse.MoveTo(startPoint + (dragIncrement * currentDragDirection))
The drag direction acts as a modifier on the drag increment. Rather than separate loops that explicitly move left or right, we use the drag direction to build this knowledge into the calculation of the drag-to point. If we calculate the drag direction again after moving the mouse move, we will automatically start dragging in the opposite direction when we go past the desired time value:
When we run our tests now, we see that...well, actually, we never get past the first test. Everything works fine until we move past the target time, but after that we just bounce back and forth to either side of the value. Just flipping direction isn't enough; we also need to reduce our drag increment each time we reverse direction:
previousDragDirection = currentDragDirection
if (previousDragDirection != currentDragDirection)
dragIncrement /= 2
Now our tests are doing much better. We still start out with a large initial drag increment, but once we pass the desired time value we binary search our way to the target time. Back and forth, back and forth we go, producing a credible imitation of a user trying to narrow in on exactly the value they want.
We still have a problem, however. All of our tests so far have placed the target time value exactly on a pixel boundary. What happens if the desired time value doesn't land directly on a pixel boundary?
Well look at that! We have ourselves a bounce fest as we bounce back and forth between 425 and 426 but never hit exactly 425.7. Eventually our drag increment reduces itself below one pixel, at which point the mouse move no longer has any effect. We need to recognize when we aren't going anywhere:
previousStartPoint = startPoint
// We're at the desired time value.
while (previousStartPoint != startPoint)
Try this out on just one test (the "move timeline item to a larger value" one, say); that works. Try the entire suite. They all pass as well. Woo-hoo! Test cases here we come!
And when your local PM stops by to tell you about the new sine wave timeline type that was accidentally added to the build last week but they've decided to keep, you have a bunch of unit tests ready and waiting to back you up as you add support for it to your test automation.