Next Chunk
We’ll pick a next chunk of our new message format and put in in place. Will we get clear to the end? Probably not yet. Also: Pyto on the iPad.
Pyto
I’ve mentioned Pyto and Pythonista for the iPad before. Both are useful, and I’m finding Pyto more useful. I have now learned how to make both unittest and pytest work in Pyto, and I know a few ways to make pytest fail to work as well. Pyto is still an iPad app, and since I don’t use the Magic Keyboard on my TV room iPad, typing anything extensive is more than a small pain, but for quick experiments, it’s quite nice. Recommended, if you’re the kind of person who wants Python on your iPad.
Robot World
OK, down to cases. There is a sort of implicit large story running here, something like “get everything using the new message format”. Starting from yesterday’s list of things to do, and adding in things that perhaps should have been there, I have this rough task list, in no particular order.
- Work out a real Cohort object and use it;
- Find, create, or stumble onto a reasonable protocol between DC, Cohort, and Bot;
- Make it easier to create the little action dictionaries;
- Provide and use an entry in DirectConnection to take a list of many actions;
- Deal with errors, including avoiding exceptions and passing error info in the knowledge.
- Location in result dictionary (JSON?)
- Refactor
execute
- Sort out adding bots
The first two, added, reflect the observation that most of our tests are about single bots interacting with the world. That is as it should be, but the point of the program is to allow a client to operate any reasonable number of Bots, batching up their commands. So we need to create the code to support that notion. The stub object, Cohort, is a guess at the object that stands in between Bot and DirectConnection, dealing with all the Bots we might have. What is not resolved at all is how the overall “main loop” might be done. Possibly we do not have to solve that for all time. Our purpose is that our client side code serves as an example of how one might do it. The specific objects on the client side are really up to the client’s authors.
Monday, Cloudy Monday
I think we’ll dig into the current handling of do_something
in Bot, which, if I’m not mistaken, will position us to think about creating a list of requests for a Bot, how that list might be processed in DirectConnection, and what Cohort’s role is in groups of Bots.
- Aside
-
I can never write down all my thoughts as I work, though I try to give you all the important ones. One that popped into my mind as I wrote the sentence above was the addition of new Bots to World. That capability needs to be built into our message protocol. It may turn out that this is a good time to do that. We’ll find out. My tentative idea is that there will be a special action in the list, perhaps with a fake bot id, that serves as a creation command. If the World were to create a bot at that moment and add its id to the list of ids to be returned, there’d be a new bot in the results. And if when we look up the bot whose dictionary needs to be updated, we noticed that we didn’t have that id, we could just create it and carry on.
-
That doesn’t seem entirely safe, but if we’re a bit careful with the protocol, it might be good. I’ve been thinking about a status entry in the returned dictionary, reflecting errors. Maybe it could also include “NEW”, or something, so we could check ourselves. Anyway, at this point, just a notion that came to mind. Having written about it, I’ve thought more than I originally did.
So, do_something
:
class Bot:
def do_something(self, connection):
actions = []
actions += self.update_for_state_machine()
self.state = self.state.update(self._knowledge)
actions += self.state.action(self._knowledge)
if random.random() < self.direction_change_chance:
actions += self.change_direction()
actions += ['step']
self.perform_actions(actions, connection)
def perform_actions(self, actions, connection):
cohort = Cohort(self)
for action in actions:
match action:
case 'take':
connection.take(cohort, self.id)
case 'drop':
# self.holding is an id
connection.drop(cohort, self.id, self.holding)
case 'step':
self._old_location = self.location
connection.step(cohort, self.id)
case 'NORTH' | 'EAST' | 'SOUTH' | 'WEST':
connection.turn(cohort, self.id, action)
case _:
assert 0, f'no case {action}'
It appears that we have a sort of list of actions in hand and that then we call the connection for each action, passing the Cohort, which only really knows one Bot at this point.
Whataaa? I think I won’t try to explain that to myself. Instead, let’s work out what should probably really happen.
Early on, we should create a Cohort to contain all our Bots. Our tests will mostly only have one, but fine, one is a number. Whatever runs our “main loop” should call the Cohort, saying do_something
. The Cohort should create a request list, and should collect all the actions from each Bot it has. It’s an open question who puts the bot identifier in the actions: one can imagine that the Bot doesn’t think of its identifier much at all, so the Cohort might have the responsibility of stuffing the id into each action.
Now as we see in perform_actions
above, the actions that the Bot produces seem to be simple strings, specifically ‘take’, ‘drop’, ‘step’, and ‘NORTH’, ‘EAST’, SOUTH’, and ‘WEST’. That’s fine. The bot needs to know what it can do in terms that make sense to it, and it can be up to Cohort to translate that to message form.
If that’s the scheme, then on the client side, only Cohort would really know about the request and return forms. That seems good.
Out of the grey fog
Clarity begins to grow
Ron forms a small smile
So it appears that, rather than pass a DirectConnection to do_something
, we will wind up without the need to pass anything. Instead, the method might just return a list that the Cohort will assemble into the appropriate format. However, if we choose that, all our tests that rely on perform_actions
will break. If they do, we can fix them by calling perform_actions
from the test. Or we can allow the passed connection to be None, and if it is present, call perform_actions
. Then we could fix tests at our leisure. I think we have enough of a plan. It remains to find a very small step we can take.
We can certainly change do_something
to return its list of actions. No one will look at it. Maybe one test, just to drive out the behavior.
I am disappointed to see that all our tests that call do_something
only check the results in the world, such as this one does:
def test_stop_at_edge(self):
world = World(10, 10)
connection = DirectConnection(world)
client_bot = connection.add_bot(9, 5)
client_bot.direction_change_chance = 0
client_bot.do_something(connection)
assert client_bot.location == Location(10, 5)
client_bot.do_something(connection)
assert client_bot.location == Location(10, 5)
client_bot.do_something(connection)
assert client_bot.location != Location(10, 5)
Really a round trip test. But we can at least grab the intended results here and check them:
def test_stop_at_edge(self):
world = World(10, 10)
connection = DirectConnection(world)
client_bot = connection.add_bot(9, 5)
client_bot.direction_change_chance = 0
actions = client_bot.do_something(connection)
assert actions == ['step']
assert client_bot.location == Location(10, 5)
actions = client_bot.do_something(connection)
assert actions == ['step']
assert client_bot.location == Location(10, 5)
actions = client_bot.do_something(connection)
assert actions[0] in ["NORTH", "SOUTH", "EAST", "WEST"]
assert actions[1] == 'step'
assert client_bot.location != Location(10, 5)
That’s more than enough to drive out this single line of code:
def do_something(self, connection):
actions = []
actions += self.update_for_state_machine()
self.state = self.state.update(self._knowledge)
actions += self.state.action(self._knowledge)
if random.random() < self.direction_change_chance:
actions += self.change_direction()
actions += ['step']
self.perform_actions(actions, connection)
return actions # <====
A bit more clarity is coming out of the mist: We could provide a new method, for testing only, that does the perform trick. That might make it easier to wean ourselves from the habit, or we could just let it ride to keep the tests running.
Anyway, we can commit: do_something
returns its actions.
Now let’s teach Cohort to create a requests list from that action list. We might even begin to evolve the Cohort into a real object instead of a stub. I’d prefer that over writing a new class and wiring it in.
class Cohort:
def __init__(self, bot):
self._bot = bot
def update(self, results):
for result_dict in results:
self._bot._knowledge.update(result_dict)
So, let’s see. This object is going to be the container for all the Bots a client is processing. It will implement do_something
, running over all the Bots and accumulating their actions. It will—I think—have a Connection that it talks with when it has a message to send, and—probably—gets called back to update. (I don’t know yet just how the connection stuff will actually work.)
Let’s write a few tests for Cohort. It doesn’t really have any: it just gets used in a few places, which was enough to drive out its so far quite minimal behavior. We’ll get a bit more formal now.
Not very formal, it turns out. This is a guess at what might be OK:
def test_cohort(self):
bot = Bot(10, 10)
cohort = Cohort(bot)
message = cohort.get_message()
assert len(message) == 1
I’m thinking that I’ll divide the do_something
into two parts, one that creates the message and one that sends it to the connection. The method get_message
will be the one that creates the message. Having said create twice, I’ll rename it. And try this:
class Cohort:
def create_message(self):
message = []
for bot in self.bots:
bot_id = bot.id
actions = bot.do_something(None)
for verb in actions:
action = {'verb': verb }
message.append(action)
return message
Test fails, no surprise there. We’re still just trying to get a vine across the chasm at this point.
The bug so far is that, although I made a point of passing a None for connection, we’re still sending messages to it. We need not to call perform_actions
if we have no connection:
class Bot:
def do_something(self, connection):
actions = []
actions += self.update_for_state_machine()
self.state = self.state.update(self._knowledge)
actions += self.state.action(self._knowledge)
if random.random() < self.direction_change_chance:
actions += self.change_direction()
actions += ['step']
if connection: # <===
self.perform_actions(actions, connection)
return actions
The test runs. It’s not much of a test, but let’s see what we can gin up to be better. I think this calls for a fake Bot.
Before I get there, I decided to improve the test a bit:
def test_cohort(self):
bot = Bot(5, 5)
bot.direction_change_chance = 0
cohort = Cohort(bot)
message = cohort.create_message()
assert len(message) == 1
assert message[0]['verb'] == 'step'
That test failed intermittently because I had not set the direction change chance. Fortunately it failed early on as I was typing something. If it had waited a bit to trigger, I’d probably have been more confused.
But we can do more with this test. For example, this surely fails:
> assert message[0]['entity'] == 101
E KeyError: 'entity'
Right, we didn’t plug that in yet. And, of course, we’re not dealing the compass directions yet.
def create_message(self):
message = []
for bot in self.bots:
bot_id = bot.id
actions = bot.do_something(None)
for verb in actions:
action = {'entity': bot_id, 'verb': verb }
message.append(action)
return message
We still fail. I suspect our Bot has no id: it has never been introduced to the World.
Expected :101
Actual :None
Right. Set it.
def test_cohort(self):
bot = Bot(5, 5)
bot.id = 101
bot.direction_change_chance = 0
cohort = Cohort(bot)
message = cohort.create_message()
assert len(message) == 1
assert message[0]['verb'] == 'step'
assert message[0]['entity'] == 101
That’ll do for a commit: Cohort beginning to understand create_message
.
Now, for a more robust test for Cohort, let’s have a Bot (fake) that issues all the possible messages, so we can check them.
def test_really_tricky_bot(self):
bot = FakeBot(101)
bot.do(['step', 'take', 'drop'])
cohort = Cohort(bot)
message = cohort.create_message()
assert len(message) == 3
assert message[0] == {'verb': 'step', 'entity': 101}
assert message[1] == {'verb': 'take', 'entity': 101}
assert message[2] == {'verb': 'drop', 'entity': 101, 'param1': 666}
Expected :{'entity': 101, 'param1': 666, 'verb': 'drop'}
Actual :{'entity': 101, 'verb': 'drop'}
The tricky part is that parameter, which Cohort doesn’t know it needs to plug in. Time to improve Cohort a bit:
Extract Method:
def create_message(self):
message = []
for bot in self.bots:
bot_id = bot.id
actions = bot.do_something(None)
for verb in actions:
action = self.create_action(bot_id, verb)
message.append(action)
return message
def create_action(self, bot_id, verb):
return {'entity': bot_id, 'verb': verb}
Test still fails, of course. But now, we can do some actual work:
def create_action(self, bot_id, verb):
match verb:
case _:
return {'entity': bot_id, 'verb': verb}
Still failing, of course. In doing the case for drop
, I see that I need the bot, not just the id. So:
def create_message(self):
message = []
for bot in self.bots:
actions = bot.do_something(None)
for verb in actions:
action = self.create_action(bot, verb)
message.append(action)
return message
def create_action(self, bot, verb):
match verb:
case 'drop':
return {'entity': bot.id, 'verb': verb, 'param1': bot.holding}
case _:
return {'entity': bot.id, 'verb': verb}
This should give me a failure with the FakeBot not knowing holding.
match verb:
case 'drop':
> return {'entity': bot.id, 'verb': verb, 'param1': bot.holding}
E AttributeError: 'FakeBot' object has no attribute 'holding'
Sweet.
class FakeBot:
@property
def holding(self):
return 666
And we are green. Commit: Cohort handles step, take, and drop.
Now we need to deal with the directions.
def test_bot_turning(self):
bot = FakeBot(101)
bot.do(['NORTH', 'EAST', 'SOUTH', 'WEST'])
cohort = Cohort(bot)
message = cohort.create_message()
assert len(message) == 4
assert message[0] == {'verb': 'turn', 'param1':'NORTH', 'entity': 101}
This will fail and drive out my solution. Let’s plug in the other three asserts first.
def test_bot_turning(self):
bot = FakeBot(101)
bot.do(['NORTH', 'EAST', 'SOUTH', 'WEST'])
cohort = Cohort(bot)
message = cohort.create_message()
assert len(message) == 4
assert message[0] == {'verb': 'turn', 'param1':'NORTH', 'entity': 101}
assert message[1] == {'verb': 'turn', 'param1':'EAST', 'entity': 101}
assert message[2] == {'verb': 'turn', 'param1':'SOUTH', 'entity': 101}
assert message[3] == {'verb': 'turn', 'param1':'WEST', 'entity': 101}
There is probably some clever way to do this with Python’s match/case. Now to learn what it is.
def create_action(self, bot, verb):
match verb:
case 'drop':
return {'entity': bot.id, 'verb': verb, 'param1': bot.holding}
case 'NORTH' | 'EAST' | 'SOUTH' | 'WEST' as direction:
return {'entity': bot.id, 'verb': 'turn', 'param1': direction}
case _:
return {'entity': bot.id, 'verb': verb}
Green. Commit: Cohort translates all Bot actions to message form.
We’re at a logical breakpoint, and two hours into the session. Good time to reflect on what we have, and perhaps to sum up.
Reflection
This feels about right to me: the Cohort is beginning to pick up the responsibility of building a message. Tests indicate that it is doing it properly, although we should do a test that drives out real multi-bot behavior and checks it. At that point, it’ll really be suitable for use in the game, and we might begin to integrate it into our existing tests.
However, what I think we really want, what we really really want, is to have bot tests that just test that the bots make the right decisions, that the World adjusts the bot’s knowledge appropriately, and that the Bot correctly updates its knowledge. All these things clearly work, with our various round-trip tests. It’s just that testing fewer objects at a time is “better”.
Would you really change those tests in the real world?
I would probably not redo tests in the real world unless they were a problem for some other reason. I would, however, try to figure out how to change them, and provide a place for the improved tests, so that future changes to Bot or World or other classes could proceed more independently. If we were really a team building a server and a team building a Bot cohort, we should not be in a position to have the two sides staying so closely in sync.
Should you have written them that way to begin with?
I was taught not to “should” all over myself, and so I reject the question and substitute my own: Would a world where you had written them that way to begin with be a somewhat better world to work in? And the answer to that question is, yes, I think it probably would be a bit better overall. But it’s not certain. My round-trip tests check in one place that both sides work correctly. If the so-called “better” world, it would take at least two tests, one for client and one for server, and we might still want at least some integration tests.
What if we were only doing a client?
Now that’s interesting, isn’t it? If we didn’t have the actual World to test against, some of the Bot’s behavior might be difficult to test. We would check the situation, much as we do now, but we’d be relying on our best guesses about what the World would send us as vision or scent, what it would do when we hit the wall, and so on. There might be documentation to tell us, but we all know that documentation doesn’t necessarily reflect reality.
A well-thought-out answer to that will require at least its own article, if not more than one. If the World was at least available over a socket connection, we could test much as we do now. But suppose it wasn’t. Suppose we were trying to get a client ready for the day the World dropped, based on a specification of what it would be. That could get tricky.
I’ll try to remember to write about that. For now, we’re reflecting on what we actually have, and what we might actually do. I think I might actually at least try to write a few one-sided tests, just so we can see what they’d be like and whether we like them. My intuition is that they’d lead us to a lot of test doubles, fakes, mocks, and all that jazz. Not a place where I start out, but I’m willing to go there when it seems helpful.
And today, it did seem helpful, and it made our tests for Cohort quite easy to set up. We Detroit School1 TDDers don’t reach for test doubles right off, but we do use them when they seem to fit.
There’s a bit of refactoring to be done, to deal with that optional connection argument and the call to perform_actions
, which Cohort subsumes. We’ll decide whether to leave the other form in for testing purposes at a later time. It’s a bit off but certainly harmless as it stands, at least for the short term.
Summary
These changes went very smoothly, not least because we wrote some simple tests to check the behavior. It’s tempting, in situations like this, to just code something up. I could do it, you could do it. I sometimes do. You, I hope, are more disciplined wiser2 than I am.
I think we’re progressing nicely. Just four commits today, all in under an hour. Not bad at all.
See you next time!
-
There are two “schools of TDD thought”, Detroit and London. London School uses a lot of test doubles, and Detroit School tends to test objects in situ. Some people refer to a “Chicago” school, but this occurs for two reasons that we know of. One reason is that people in the UK and Europe can often only think of New York and Chicago as cities in the US, and they know it’s not New York so it must be Chicago. They are mistaken. The other reason is that a schismatic group, the “Craftsmen”, like to think of their home base, Chicago, as representing the US-side style of TDD. This is simply, well, wrong. TDD as we do it started in Detroit, or Centerline, to be precise, long before it was ever taken up in Chicago. Does this matter at all, in any way whatsoever? No, not a bit. As you were. ↩
-
Why do I not say “disciplined”? Because I am a free person and I want you to be a free person. I don’t do things because some book or allegedly benevolent indirect relation says we can’t wear the programmer hat unless we do certain things. I do them because I’ve tried them and they work for me. If I were to give advice, I’d advise you to do the same. Sure, listen to all the experts, but do your own experiments and make your own decisions. That would be my advice, if I were to give advice, which I will not be doing just now. ↩