Kotlin 274 - Better Timers
GitHub Decentralized Repo
GitHub Centralized Repo
GitHub Flat Repo
Now that we’ve got the PluggableTimer working, let’s make two that should be a bit more useful. Added in Post: You may touch the hem of my garment.
Our PluggableTimer knows nothing about its entity
, just passes it to the action
when the time runs out. Since all our SpaceObjects can be active or inactive, any action
we implement would almost certainly need to be conditioned on whether or not the object is active: it’s hard to think of a timed action that would want to be the same either way.
We “need” something better. Or, to be more accurate, I want something better. I see two possibilities and I have a preference for one of them.
- We could have a single timer with two actions, one if active and one if inactive;
- We could have two timers, one with an action when active, and one when inactive.
I think the latter is better, mostly because Kotlin is really good about allowing a single block parameter and not so good about two. I could be wrong, but I’m not uncertain, so let’s do some more tests and create one of each. The hard part is naming them. How about ActionTimer and IdleTimer? I was taught to put the modifier part of a class name first, so don’t feel right about TimeAction and TimeInaction. We’ll try those names, we can always rename later.
ActionTimer
Begin with a test.
@Test
fun `action timer does not tick on inactive entity`() {
val entity = newAsteroid()
assertThat(entity.active)
.describedAs("initialized to false")
.isEqualTo(false)
executed = false
val timerTime = 1.0
val timer = ActionTimer(entity, timerTime) { executed = true}
timer.update(0.5)
assertThat(timer.time)
.describedAs("should be unchanged")
.isEqualTo(timerTime)
}
That’s enough to push out most of the new object, and in fact I’m going to do the whole thing.
Test, green. Commit: initial ActionObject test and class.
I kind of regret writing all that untested code. It’s better to go small. But that’s OK, I’m not done testing.
@Test
fun `action timer ticks on active entity`() {
val entity = newAsteroid()
entity.active = true
executed = false
val timerTime = 1.0
val timer = ActionTimer(entity, timerTime) { executed = true}
timer.update(0.5)
assertThat(timer.time)
.describedAs("should be changed")
.isEqualTo(timerTime-0.5, within(0.01))
}
That’s green too. Commit: ActionTimer tested to tick only when entity active.
Now test for acting.
@Test
fun `action timer acts on active entity time elapsed`() {
val entity = newAsteroid()
entity.active = true
executed = false
val timerTime = 1.0
val timer = ActionTimer(entity, timerTime) { executed = true}
timer.update(1.0)
assertThat(executed)
.isEqualTo(true)
.describedAs("action should be taken")
}
That runs, of course. Commit: ActionTimer tested to take action on time elapsed.
Now the reset:
@Test
fun `action timer resets time on time elapsed`() {
val entity = newAsteroid()
entity.active = true
executed = false
val timerTime = 1.0
val timer = ActionTimer(entity, timerTime) { executed = true}
timer.update(0.5)
assertThat(timer.time)
.describedAs("should be changed")
.isEqualTo(timerTime-0.5, within(0.01))
timer.update(0.5)
assertThat(timer.time)
.describedAs("should be reset")
.isEqualTo(timerTime)
}
This, too, runs green. Commit: ActionTimer resets on time elapsed.
This is good. Now I want the IdleTimer, just the same except for one line. Let’s build it bit by bit instead of copying and pasting, just to show what’s usually a better way.
IdleTimer
First test will just drive out the class, then extend.
@Test
fun `idle timer can be created`() {
val entity = newAsteroid()
val timer = IdleTimer(entity, 1.0) {}
assertThat(timer).isNotEqualTo(null)
}
Right, create the class:
class IdleTimer(
override val entity: SpaceObject,
val delayTime: Double,
val action: (IdleTimer) -> Unit
): Component {}
I expect green. I get it. Commit: IdleTimer constructor tested.
IDEA reminds me of what I already knew, the parameters are not used. What shall we test next? Let’s test that it doesn’t tick, like the other test. I will copy and edit that.
@Test
fun `idle timer does not tick on active entity`() {
val entity = newAsteroid()
entity.active = true
val timerTime = 1.0
val timer = IdleTimer(entity, timerTime) { }
timer.update(0.5)
assertThat(timer.time)
.describedAs("should be unchanged")
.isEqualTo(timerTime)
}
That drives out both update
and time
:
class IdleTimer(
override val entity: SpaceObject,
val delayTime: Double,
val action: (IdleTimer) -> Unit
) : Component {
var time = delayTime
fun update(deltaTime: Double) {
if (entity.active) return
}
}
I think we should be green so far. We are. Commit: IdleTimer does not tick when entity is active
We get warnings that action and deltaTime are not used. We knew that. Patience, Prudence, your time will come.
@Test
fun `idle timer does tick on inactive entity`() {
val entity = newAsteroid()
entity.active = false
val timerTime = 1.0
val timer = IdleTimer(entity, timerTime) { }
timer.update(0.5)
assertThat(timer.time)
.describedAs("should be changed")
.isEqualTo(timerTime-0.5, within(0.01))
}
This one should fail “should be changed”. It does. Implement:
class IdleTimer(
override val entity: SpaceObject,
val delayTime: Double,
val action: (IdleTimer) -> Unit
) : Component {
var time = delayTime
fun update(deltaTime: Double) {
if (entity.active) return
time -= deltaTime
}
}
Should pass. Does. Commit: IdleTimer ticks on inactive entity.
You’d think it would feel silly to me, doing a test to get one line of code and then committing it, but it doesn’t. It feels confident and rhythmic. One more test, I think. No, two, one for the time reset, one for the action.
@Test
fun `idle timer resets time when time elapsed`() {
val entity = newAsteroid()
entity.active = false
val timerTime = 1.0
val timer = IdleTimer(entity, timerTime) { }
timer.update(0.5)
assertThat(timer.time)
.describedAs("should be changed")
.isEqualTo(timerTime-0.5, within(0.01))
timer.update(0.5)
assertThat(timer.time)
.describedAs("should be reset")
.isEqualTo(timerTime)
}
I expect failure with “should be reset” and time about 0.
org.opentest4j.AssertionFailedError: [should be reset]
expected: 1.0
but was: 0.0
I do love it when a plan comes together. Implement:
class IdleTimer(
override val entity: SpaceObject,
val delayTime: Double,
val action: (IdleTimer) -> Unit
) : Component {
var time = delayTime
fun update(deltaTime: Double) {
if (entity.active) return
time -= deltaTime
if (time <= 0.0) {
time = delayTime
}
}
}
I expect green. I get it. Commit: IdleTimer resets time on time elapsed.
One more test, doing the thing. Executing the action.
@Test
fun `idle timer takes action on inactive entity when time elapsed`() {
val entity = newAsteroid()
entity.active = false
executed = false
val timerTime = 1.0
val timer = ActionTimer(entity, timerTime) { executed = true}
timer.update(1.0)
assertThat(executed)
.describedAs("action should be taken")
.isEqualTo(true)
}
I expect a fail on “action should be taken”. I get it. Fix:
class IdleTimer(
override val entity: SpaceObject,
val delayTime: Double,
val action: (IdleTimer) -> Unit
) : Component {
var time = delayTime
fun update(deltaTime: Double) {
if (entity.active) return
time -= deltaTime
if (time <= 0.0) {
action(this)
time = delayTime
}
}
}
I’m looking for green. I do not get it. Why not? Ah. I used ActionTimer not IdleTimer. This is why I should never copy-paste anything, ever. Fixed. We are green.
Commit: IdleTimer complete, takes action on time elapsed on inactive object.
I want to put these babies into action but first I think I’d like to clean up the ActionTimer tests a bit based on what I did here.
@Test
fun `action timer does not tick on inactive entity`() {
val entity = newAsteroid()
entity.active = false
val timerTime = 1.0
val timer = ActionTimer(entity, timerTime) { executed = true}
timer.update(0.5)
assertThat(timer.time)
.describedAs("should be unchanged")
.isEqualTo(timerTime)
}
@Test
fun `action timer ticks on active entity`() {
val entity = newAsteroid()
entity.active = true
val timerTime = 1.0
val timer = ActionTimer(entity, timerTime) { executed = true}
timer.update(0.5)
assertThat(timer.time)
.describedAs("should be changed")
.isEqualTo(timerTime-0.5, within(0.01))
}
@Test
fun `action timer acts on active entity time elapsed`() {
val entity = newAsteroid()
entity.active = true
executed = false
val timerTime = 1.0
val timer = ActionTimer(entity, timerTime) { executed = true}
timer.update(1.0)
assertThat(executed)
.isEqualTo(true)
.describedAs("action should be taken")
}
@Test
fun `action timer resets time on time elapsed`() {
val entity = newAsteroid()
entity.active = true
val timerTime = 1.0
val timer = ActionTimer(entity, timerTime) { executed = true}
timer.update(0.5)
assertThat(timer.time)
.describedAs("should be changed")
.isEqualTo(timerTime-0.5, within(0.01))
timer.update(0.5)
assertThat(timer.time)
.describedAs("should be reset")
.isEqualTo(timerTime)
}
Let’s reflect.
Reflection.
I did these two objects differently. For the first one, I basically implemented the whole thing and then tested it. For the second, I used equivalent tests but only coded the actual lines I needed to make the newest test pass, to the best of my ability. I committed the IdleTimer after every test passed, for a total of five commits for 15 lines of code.
The elapsed time was about the same for each case, and for the IdleTimer, my confidence was higher because every line of code had a reason for being, tied back to a test and necessary to get the test to green.
I like the second way better. When things go well, it’s not discernibly slower, and when things go poorly, as they did when I copied and pasted a test and didn’t fix it up correctly, the problems are easier to find.
And, most important, I am far more likely to do the tests if I work test then code. It’s way too tempting to read the code and decide that it works, even though it is essentially untested.
I did notice one thing. The condition for time elapsed is time<=0.0
. My tests test equality, so there is no test that checks for going below zero. In principle, there could be a defect in the object, if it tested for time==0.0
, such that it wouldn’t work unless the subtraction came out even.
That’s probably a flaw in my tests. Yes, it is a flaw. I don’t want to write the tests, I want to be done. (See what I mean about the temptation?) Let’s do them.
@Test
fun `idle timer triggers and resets on time going negative`() {
val entity = newAsteroid()
entity.active = false
executed = false
val timerTime = 1.0
val timer = IdleTimer(entity, timerTime) { executed = true}
timer.update(1.1)
assertThat(executed)
.describedAs("action should be taken")
.isEqualTo(true)
assertThat(timer.time)
.describedAs("should be reset")
.isEqualTo(timerTime)
}
@Test
fun `action timer triggers and resets on time going negative`() {
val entity = newAsteroid()
entity.active = true
executed = false
val timerTime = 1.0
val timer = ActionTimer(entity, timerTime) { executed = true}
timer.update(1.1)
assertThat(executed)
.describedAs("action should be taken")
.isEqualTo(true)
assertThat(timer.time)
.describedAs("should be reset")
.isEqualTo(timerTime)
}
I did allow myself two assertions per test, and I did copy and paste. They’re both green. Commit: Check IdleTimer and ActionTimer for triggering on non-zero negative time.
I feel amazingly righteous right now. You may touch the hem of my garment.
I’m not going to apply these objects now, I’m going to sum up and read my book.
Summary
I’m writing these two objects speculatively, but I have every intention of using them on the Ship and Saucer and Missile to support their various timing needs. And I expect to allow them to continue to have an update method, and I might even give them a reset method as well, to be used when the entity is deactivated. I’m not sure about that yet. But we’ll have to remember to do something of the kind.
For now, on a little weekend excursion, I feel that I’ve made the world a slightly better place, although the proof won’t appear for another day or so.
See you next time!