Dungeon 101
A second cut at TDDing the music players. Simple isn’t easy.
In my quest to learn better and easier ways to use TDD on things, I plan to work further on the spike I started yesterday. My intention is to simplify the music players and get them to a point where I unquestionably prefer them to thee hand-crafted untested one that’s in there now.
I plan to make them simpler, where possible, and to that end, I’ve come up with what I think are simpler requirements.
- MusicPlayer
- Given a
playlist
table of name=asset, plays an asset by name, looped, and can return the name of the asset now playing. No default tune is played. If needed in testing, can accept an alternative ‘music’ function. If told to play an asset that is now playing, does not restart the asset, just lets it continue. - SmoothPlayer
- Given a
playlist
table, and adelay
in seconds, initializes a MusicPlayer. Can return the name of the asset now playing, via MusicPlayer. No default tune is played. Ensures that anything played plays for at leastdelay
seconds. If need be, delays asking for a new tune untildelay
has elapsed, then asks. - MonsterPlayer
- Creates a playlist for “dungeon” and “battle” music, initializes a SmoothPlayer with a delay of 3 seconds. Checks frequently to see if monsters are near player and if so, switches music to “battle”, otherwise switches it to “dungeon”.
I think that covers it. If new requirements come to light, I’ll edit the above with additions in italic. Let’s get to it.
MusicPlayer
We’ll start with the tests that exist, trim them and tune them, then ensure that the objects are suitable. That is, I’m not planning to start over on the existing objects from yesterday, only to refine them.
Here are the tests:
_:test("Initial song is default", function()
local mp = MusicPlayer(nil,fakeMusic)
_:expect(mp:nowPlaying()).is("default")
_:expect(tunePlaying).is(asset.downloaded.A_Hero_s_Quest.Dungeon)
end)
_:test("Song is battle when requested", function()
local mp = MusicPlayer(nil,fakeMusic)
mp:play("battle")
_:expect(mp:nowPlaying()).is("battle")
_:expect(tunePlaying).is(asset.downloaded.A_Hero_s_Quest.Battle)
end)
_:test("Song goes back to dungeon when requested", function()
local mp = MusicPlayer(nil,fakeMusic)
mp:play("battle")
mp:play("dungeon")
_:expect(mp:nowPlaying()).is("dungeon")
_:expect(tunePlaying).is(asset.downloaded.A_Hero_s_Quest.Dungeon)
end)
_:test("Player plays nothing if tune is unknown", function()
local mp = MusicPlayer(nil,fakeMusic)
tunePlaying = "never set"
mp:play("unknown")
_:expect(tunePlaying).is("never set")
end)
_:test("Player accepts alternative playlist", function()
local playList = {a=asset.downloaded.A_Hero_s_Quest.Arrow_Shoot_1, b=asset.downloaded.A_Hero_s_Quest.Arrow_Shoot_2, default=asset.downloaded.A_Hero_s_Quest.Curse }
local mp = MusicPlayer(playList, fakeMusic)
_:expect(tunePlaying).is(asset.downloaded.A_Hero_s_Quest.Curse)
end)
We’re not creating our music list by default, so we’ll need one in the tests. And we don’t want a default tune any more.
local MonsterPlayList = {battle=asset.downloaded.A_Hero_s_Quest.Battle, dungeon=asset.downloaded.A_Hero_s_Quest.Dungeon}
I replace the first test with this:
_:test("Player does not start until requested", function()
local mp = MusicPlayer(MonsterPlaylist,fakeMusic)
_:expect(mp:nowPlaying()).is(nil)
end)
I expect this to fail and drive out some behavior.
1: Player does not start until requested -- Tests:133: attempt to index a nil value (field 'tunes')
I’d better show MusicPlayer before it gets changed, so you can see what I’m doing.
MusicPlayer = class()
function MusicPlayer:init(playList, musicFunction)
self.music = musicFunction or music
self.tunes = playList or {}
end
function MusicPlayer:nowPlaying()
return self.playing
end
function MusicPlayer:play(tuneName)
local tune = self:tuneFor(tuneName)
if tune then
self.playing = tuneName
self.music(tune, true)
end
end
function MusicPlayer:tuneFor(tuneName)
return self.tunes[tuneName]
end
In the above, I’ve already changed the init
to make the first test pass. Many others are failing, mostly for not initializing the play list. I’ll change them one at a time, I guess, though I am tempted to blast through and do them all. More thinking, less blasting, might be the best idea.
2: Song is battle when requested -- Actual: nil, Expected: battle
Test needs the playlist. Still fails same way. I am surprise.
I have typed MonsterPlayList when I meant MonsterPlaylist. And the local needs to be at the top of the tab or the tests don’t know it and they get nil.
Test 2 works. And:
3: Song goes back to dungeon when requested -- Actual: nil, Expected: dungeon
Needs to be passed the playlist. Works. Test 4 also works:
_:test("Player plays nothing if tune is unknown", function()
local mp = MusicPlayer(MonsterPlaylist,fakeMusic)
tunePlaying = "never set"
mp:play("unknown")
_:expect(tunePlaying).is("never set")
end)
The fifth test is this:
_:test("Player accepts alternative playlist", function()
local playList = {a=asset.downloaded.A_Hero_s_Quest.Arrow_Shoot_1, b=asset.downloaded.A_Hero_s_Quest.Arrow_Shoot_2, default=asset.downloaded.A_Hero_s_Quest.Curse }
local mp = MusicPlayer(playList, fakeMusic)
_:expect(tunePlaying).is(asset.downloaded.A_Hero_s_Quest.Curse)
end)
That’s redundant since we now pass in the list and we’re sure that the list works. Delete.
That’s all the tests for MusicPlayer. Let’s review it, and our requirements, and see what we think.
MusicPlayer = class()
function MusicPlayer:init(playList, musicFunction)
self.music = musicFunction or music
self.tunes = playList or {}
end
function MusicPlayer:nowPlaying()
return self.playing
end
function MusicPlayer:play(tuneName)
local tune = self:tuneFor(tuneName)
if tune then
self.playing = tuneName
self.music(tune, true)
end
end
function MusicPlayer:tuneFor(tuneName)
return self.tunes[tuneName]
end
I think that’s pretty tight. We could inline that last method. Want to? OK:
function MusicPlayer:play(tuneName)
local tune = self.tunes[tuneName]
if tune then
self.playing = tuneName
self.music(tune, true)
end
end
Better, or worse? One?, or Two? I prefer the method, it expresses my idea better. Putting it back.
First four tests are running. (We deleted the old #5, remember.) Last test is for SmoothPlayer and it fails:
_:test("Smooth player doesn't accept new tune until time elapsed", function()
local sp = SmoothPlayer(0,nil,fakeMusic)
sp:play("battle")
_:expect(sp:nowPlaying()).is("default")
sp:unlock()
_:expect(sp:nowPlaying()).is("battle")
end)
Errors tell me to pass in the playlist:
_:test("Smooth player doesn't accept new tune until time elapsed", function()
local sp = SmoothPlayer(0,MonsterPlaylist,fakeMusic)
sp:play("battle")
_:expect(sp:nowPlaying()).is("default")
sp:unlock()
_:expect(sp:nowPlaying()).is("battle")
end)
Now the test fails looking for default. We need to decide what to do here. Let’s do this test over from scratch rather than try to debug it. We might as well follow the pattern we expect in the game, namely starting with “dungeon” and then going to “battle” and back.
_:test("Smooth player doesn't accept new tune until time elapsed", function()
local sp = SmoothPlayer(0,MonsterPlaylist,fakeMusic)
sp:play("dungeon")
sp:play("battle")
_:expect(sp:nowPlaying()).is("dungeon")
sp:unlock()
_:expect(sp:nowPlaying()).is("battle")
end)
We have that zero being passed in to SmoothPlayer. That’s the delay, used to turn off the tween. For now we’ll let that be, but I really want to test the tween calls if I can think of a decent way to do it.
Will this test run? Let’s find out. First assert fails:
5: Smooth player doesn't accept new tune until time elapsed -- Actual: nil, Expected: dungeon
Code is bad. Here it is as it stands:
SmoothPlayer = class()
function SmoothPlayer:init(delay, playList, musicFunction)
self.delay = delay or 0
self.mp = MusicPlayer(playList, musicFunction)
self.onDeck = self.mp:nowPlaying()
self:setTimer()
end
function SmoothPlayer:nowPlaying()
return self.mp:nowPlaying()
end
function SmoothPlayer:play(tune)
self.onDeck = tune
end
function SmoothPlayer:playOnDeckTune()
if self:nowPlaying() ~= self.onDeck then
self.mp:play(self.onDeck)
end
end
function SmoothPlayer:setTimer()
if self.delay == 0 then return end
tween.delay(self.delay, self.unlock, self)
end
function SmoothPlayer:unlock()
self:playOnDeckTune()
self:setTimer()
end
The init
is overly elaborate now. We should set onDeck
to nil, as we know from here that MP isn’t playing anything. And there’s no reason to set the timer, which should be set when we play something.
function SmoothPlayer:init(delay, playList, musicFunction)
self.delay = delay or 0
self.mp = MusicPlayer(playList, musicFunction)
self.onDeck = nil
end
Error is still:
5: Smooth player doesn't accept new tune until time elapsed -- Actual: nil, Expected: dungeon
Our play logic in SmoothPlayer is no long up to spec. Let’s remove all its methods and test-drive it anew.
5: Smooth player doesn't accept new tune until time elapsed -- Tests:47: attempt to call a nil value (method 'play')
Ah, good old TDD. Build a simple play
:
function SmoothPlayer:play(aTune)
self.mp:play(aTune)
end
This is of course wrong, as it is not sensitive to the timer, which we don’t have. We’ll get there.
5: Smooth player doesn't accept new tune until time elapsed -- Tests:49: attempt to call a nil value (method 'nowPlaying')
function SmoothPlayer:nowPlaying()
return self.mp:nowPlaying()
end
This should fail finding the wrong tune.
5: Smooth player doesn't accept new tune until time elapsed -- Actual: battle, Expected: dungeon
We accepted the tune immediately. We have to wait for the unlock as the test demands:
_:test("Smooth player doesn't accept new tune until time elapsed", function()
local sp = SmoothPlayer(0,MonsterPlaylist,fakeMusic)
sp:play("dungeon")
sp:play("battle")
_:expect(sp:nowPlaying()).is("dungeon")
sp:unlock()
_:expect(sp:nowPlaying()).is("battle")
end)
This is somewhat subtle. We could just put our call to play in unlock, but then our first song wouldn’t play. We need to have some kind of “ready” flag that toggles, and we’re going to need to … to what? One thing at a time.
We’ll play if ready:
function SmoothPlayer:init(delay, playList, musicFunction)
self.delay = delay or 0
self.mp = MusicPlayer(playList, musicFunction)
self.onDeck = nil
self.ready = true
end
function SmoothPlayer:play(aTune)
if self.ready then
self.mp:play(aTune)
self.ready = false
end
end
This should progress the test.
5: Smooth player doesn't accept new tune until time elapsed -- Tests:50: attempt to call a nil value (method 'unlock')
We need unlock, which is trivial:
function SmoothPlayer:unlock()
self.ready = true
end
Test should still fail.
5: Smooth player doesn't accept new tune until time elapsed -- Actual: dungeon, Expected: battle
The time has elapsed (I’m assuming that unlock is called by the tween, by the way) and we have not played the song.
Upon unlock, we want to initiate play if the song isn’t already playing. We’ll need to record what it is, I suppose.
function SmoothPlayer:unlock()
self.ready = true
self:playIfNotAlreadyPlaying()
end
function SmoothPlayer:playIfNotAlreadyPlaying()
if self.onDeck ~= self:nowPlaying() then
self:play(self.onDeck)
end
end
Now I’ve not recorded the on deck tune, and I am aware of that, but if I had forgotten the test would tell me by continuing to fail:
5: Smooth player doesn't accept new tune until time elapsed -- Actual: dungeon, Expected: battle
So:
function SmoothPlayer:play(aTune)
self.onDeck = aTune
if self.ready then
self.mp:play(aTune)
self.ready = false
end
end
Now I expect this test to pass. And it does.
However, we have not set the timer. Yesterday, I just figured it out and put it in. And I wanted to be able to test with a delay that could be set to zero, which is reflected in the test we already have. Let’s not do that. Instead, let’s bite the bullet and mock tween
. Should we default the delay to zero now? That seems risky. Let’s default it to 3.
Now what about testing the timer? Let’s enhance test 5 to ask questions about the tween, and then try to answer them.
I need to think about this. I need to think so hard that I need a new heading.
TweenTime
When we actually start playing a tune, we want to set a delay tween for delay
seconds. When that tween fires, we want it to cal unlock
. We do not want it to start again … but if the on deck tune is different from the now-playing tune, the on deck will be played and that will start the tween again.
We could cater for an early call to unlock
but let’s not. Let’s just check to see when tween.delay is called and what it’s called with.
I think what I want is this:
_:test("Smooth player doesn't accept new tune until time elapsed", function()
tweentime = nil
local sp = SmoothPlayer(4,MonsterPlaylist,fakeMusic)
_:expect(tweenTime).is(nil)
sp:play("dungeon")
_:expect(tweenTime).is(4)
sp:play("battle")
_:expect(tweenTime).is(4)
_:expect(sp:nowPlaying()).is("dungeon")
sp:unlock()
_:expect(tweenTime).is(4)
_:expect(sp:nowPlaying()).is("battle")
tweenTime = nil
sp:unlock()
_:expect(tweenTime).is(nil)
end)
The player starts with no tween time set. When it plays its first tune, it starts immediately and tween time should be set to 4 because 4 is our number. (I have just changed my mind about the default. Provide whatever number you want, it’s the first parm.)
This code is written to assume that somehow tweentime
gets set. My plan is to provide a fake tweendelay
function, like the fake music one.
_:test("Smooth player doesn't accept new tune until time elapsed", function()
tweentime = nil
local sp = SmoothPlayer(4,MonsterPlaylist,fakeMusic, fakeTweenDelay)
_:expect(tweenTime).is(nil)
sp:play("dungeon")
...
function fakeTweenDelay(time)
tweentime = time
end
5: Smooth player doesn't accept new tune until time elapsed -- Actual: nil, Expected: 4
No surprise, we’re not setting the timer yet.
function SmoothPlayer:playIfNotAlreadyPlaying()
if self.onDeck ~= self:nowPlaying() then
self:play(self.onDeck)
self.tweenDelay(self.delay, self.unlock, self)
end
end
I rather expect this to work, unless the test is wrong. Let’s see:
5: Smooth player doesn't accept new tune until time elapsed -- Actual: nil, Expected: 4
Of course I’m not totally sure which one this is, so let’s enhance the test.
_:test("Smooth player doesn't accept new tune until time elapsed", function()
tweenTime = 9
local sp = SmoothPlayer(4,MonsterPlaylist,fakeMusic, fakeTweenDelay)
_:expect(tweenTime, "at start").is(9)
sp:play("dungeon")
_:expect(tweenTime, "after dungeon").is(4)
sp:play("battle")
_:expect(tweenTime, "after battle attempt").is(4)
_:expect(sp:nowPlaying()).is("dungeon")
tweentime = 99
sp:unlock()
_:expect(tweenTime, "after delayed play of battle").is(4)
_:expect(sp:nowPlaying()).is("battle")
tweenTime = 999
sp:unlock()
_:expect(tweenTime).is(999)
end)
CodeaUnit lets me put a comment after the is
parameter, and displays that as part of any error messages.
I’ve also tried hard to always use tweenTime
and not tweentime
. I really need to do something about newly created undefined variables. Anyway …
5: Smooth player doesn't accept new tune until time elapsed after dungeon -- Actual: 9, Expected: 4
5: Smooth player doesn't accept new tune until time elapsed after battle attempt -- Actual: 9, Expected: 4
OK, let’s see what’s up here. tweenTime doesn’t turn from 9 until 4 until “after delayed play of battle”. It should get set on the first call, to play “dungeon”. And I should probably consider overwriting the second 4 there. One thing at a time.
Ah. Here’s the relevant code:
function SmoothPlayer:play(aTune)
self.onDeck = aTune
if self.ready then
self.mp:play(aTune)
self.ready = false
end
end
function SmoothPlayer:playIfNotAlreadyPlaying()
if self.onDeck ~= self:nowPlaying() then
self:play(self.onDeck)
self.tweenDelay(self.delay, self.unlock, self)
end
end
We just call in on play
with no checking, so we never set the timer. What’s the right sequence here?
We certainly want to save whatever’s requested as onDeck
. But then, if we’re not ready, we shouldn’t play. If we are ready, then if it’s not what’s already playing, we should play it and set the timer. Also, where we set the timer is where we should set not ready. So how about this?
function SmoothPlayer:play(aTune)
self.onDeck = aTune
if self.ready then
self:playIfNotAlreadyPlaying()
end
end
function SmoothPlayer:playIfNotAlreadyPlaying()
if self.onDeck ~= self:nowPlaying() then
self:play(self.onDeck)
self.tweenDelay(self.delay, self.unlock, self)
self.ready = false
end
end
Whee!
5: Smooth player doesn't accept new tune until time elapsed -- Tests:101: stack overflow
That’s not good. We had best not call play after play has called us. Should have said:
function SmoothPlayer:playIfNotAlreadyPlaying()
if self.onDeck ~= self:nowPlaying() then
self.mp:play(self.onDeck)
self.tweenDelay(self.delay, self.unlock, self)
self.ready = false
end
end
OK, my test now runs. Let’s review it
_:test("Smooth player doesn't accept new tune until time elapsed", function()
tweenTime = 9
local sp = SmoothPlayer(4,MonsterPlaylist,fakeMusic, fakeTweenDelay)
_:expect(tweenTime, "at start").is(9)
sp:play("dungeon")
_:expect(tweenTime, "after dungeon").is(4)
sp:play("battle")
_:expect(tweenTime, "after battle attempt").is(4)
_:expect(sp:nowPlaying()).is("dungeon")
tweentime = 99
sp:unlock()
_:expect(tweenTime, "after delayed play of battle").is(4)
_:expect(sp:nowPlaying()).is("battle")
tweenTime = 999
sp:unlock()
_:expect(tweenTime).is(999)
end)
We set tweenTime to an impossible value, then start the player, which does not start the timer. We play “dungeon”, which is new, so it sets the timer to 4. We try to play “battle”, which does not play and the timer is still 4. We’re not sure if the delay was called, so let’s clobber the value and do it this way:
_:test("Smooth player doesn't accept new tune until time elapsed", function()
tweenTime = 9
local sp = SmoothPlayer(4,MonsterPlaylist,fakeMusic, fakeTweenDelay)
_:expect(tweenTime, "at start").is(9)
sp:play("dungeon")
_:expect(tweenTime, "after dungeon").is(4)
tweenTime = 8
sp:play("battle")
_:expect(tweenTime, "after battle attempt").is(8)
_:expect(sp:nowPlaying()).is("dungeon")
tweentime = 99
sp:unlock()
_:expect(tweenTime, "after delayed play of battle").is(4)
_:expect(sp:nowPlaying()).is("battle")
tweenTime = 999
sp:unlock()
_:expect(tweenTime).is(999)
end)
That continues to work. So calling for “battle” does not play it, and does not set the timer (which was already set when we played “dungeon”). Now the time runs out (we call unlock
directly). We expect that battle will start playing and that tweenTime will again be 4. And it is. When we unlock again, the on deck tune has not changed, so the timer is not set.
That seems right to me.
And we’ve actually tested that we’re getting the right calls to the tween.delay
, at least as regards the time set.
Are We There Yet?
What about the MonsterPLayer? It looks like this:
MonsterPlayer = class()
function MonsterPlayer:init(runner)
self.runner = runner
self.sp = SmoothPlayer(3)
tween.delay(1, self.everySoOften, self)
end
function MonsterPlayer:checkForDanger()
local close = self.runner:hasMonsterNearPlayer(5)
if close then self.sp:play("battle") else self.sp:play("dungeon") end
end
function MonsterPlayer:everySoOften()
self:checkForDanger()
tween.delay(1, self.everySoOften, self)
end
And we have no tests for it, not one, zero, not any. There’s not much there to test. With our new notion that the players don’t know their play list, we need to put that in here.
There’s not enough branching logic here to justify testing even in my present frame of mind. Let’s paste all this stuff over and see what happens.
To plug it in, we change GameRunner:
function GameRunner:init()
self.tileSize = 64
self.tileCountX = 85 -- if these change, zoomed-out scale
self.tileCountY = 64 -- may also need to be changed.
self.tiles = {}
for x = 1,self.tileCountX+1 do
self.tiles[x] = {}
for y = 1,self.tileCountY+1 do
local tile = Tile:edge(x,y, self)
self:setTile(tile)
end
end
self.cofloater = Floater(self, 50,25,4)
self.musicPlayer = MonsterPlayer(self)
end
It works nicely, as we see here:
Yesterday’s version waited a bit too long to start the music or switch it. Today’s works just like I had in mind.
This time, I think we’ll keep the new tests and code, and sum up.
Summing Up
The commit says -30+130 lines. Total lines are now 150, of which 76 are test, 74 code. The old MusicPlayer was 46 lines and did everything, untested, in one class. If we need no additional music changes, this exercise will surely not pay off. If there are, I suspect they’ll be easier in this setup. Most likely, MonsterPlayer would have to change or be replaced, but the other two classes are just about guaranteed to stand.
I can easily generate another requirement for music. I think it was Bryan who suggested a happy tune if the princess finds a treasure. Perhaps it was Asa who suggested a different tune at the beginning of the game, or the end. In any case, we could “force” the issue by showing how easily new music requirements can be put up in this bi…tty little program, but right now, we have other fish to fry.
It builds the stack
One thing that I consider iffy about this design is that the stack is all built internally, the MonsterPlayer creates its own SmoothPlayer, which creates its own MusicPlayer. It could be argued that those should be injected somehow, or that there should be factory methods, perhaps like:
function MonsterPlayer:withPlayer(runner, aPlayer)
That would provide the flexibility of plugging in other kinds of players. This might be true, but by me it’s more generality than we need at this time, so, no.
It has extra mechanism
The ability to provide fake functions just for testing is a bit concerning, but it did turn out to be useful. We built a fakeMusic
function with enough of the music
protocol to let us detect what tune we were playing, and a fakeTweenDelay1 function that let us do the same with
tween.delay`.
It might be possible–probably would be possible–to do this by masking the global objects music
and tween
or tween.delay
during tests and then unmasking them later. Or we could provide separate testing-related functions that we could call to inject the fakes.
Finally, it would also be possible to make those functions required parameters instead of optional, and to provide the real ones in our real calls to create the players.
Same-same in my book, and better to allow most users just to pass in the things they care about, the delays and playlists.
Dependencies
Codea allows a project to declare dependencies, references from one project to another. Basically it imports all the tabs from the other project other than Main. So we could, in principle, arrange my separate spike project, MusicPlayer, such that we could just import the player classes. That would save source code and tabs in the main Dung project, but with the disadvantage that changes to the music would probably require changes to that other project first.
For my purposes, I’m happy to copy and paste over something like this, but if I ever decide to use it again, setting up for dependency might be worth it.
My bottom line
At least for now, I’m glad we tried this second pass at the MusicPlayer, because the one we had was intricate enough that it doesn’t quite obviously work, and that’s a sign that a thing probably ought to be tested.
I’m also pleased because I found a way to test this kind of thing a bit more easily, with the injection of fake testing functions that I could use to check necessary callbacks and the like. That’s a thing that test doubles are good at and, for me, using a fake function is a bit lighter weight than using a test double object. We could have done it that way, of course, creating either a FakeMusic
and FakeTween
object, or a single object with both aspects.
Then we’d have more code on the testing side, and we’d need more code on the working side, likely including at least one new object, since neither music nor tweens are covered by objects, only functions. In my view, not a winner over what we have now.
All in all, I’m satisfied with this exercise, but it’s because of the learning more than the impact on the overall program or project. Yes, the players are arguably better-structured, at the cost of nearly double the code, and better tested, at the cost of tripling the code size if we leave the tests in the dungeon program.
If my manager were to ask the project team why we spent two whole mornings creating tests and better code for something that already worked, my arguments would all be about the future. Learning lets us go faster and now we’re more able to do additional music and now we can be sure that it works, and so on, singing and dancing.
But for me, and for us, sitting right here, I feel the investment was worth it.
But I’d have to say that, would’t I?
See you next time!